mirror of
https://github.com/vndangkhoa/Sys-Arc-Visl.git
synced 2026-04-05 01:17:57 +07:00
feat: v1.1.0 release - integrated AI vision, smart tools, and themes
This commit is contained in:
commit
0c0297ffee
72 changed files with 17571 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Environment Variables
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# AI Configuration (Optional - can also be set in app settings)
|
||||||
|
VITE_DEFAULT_OLLAMA_URL=http://localhost:11434
|
||||||
|
VITE_DEFAULT_MODEL_NAME=llama3.2-vision
|
||||||
|
|
||||||
|
# OpenAI (Optional)
|
||||||
|
VITE_OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Google Gemini (Optional)
|
||||||
|
VITE_GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# App Configuration
|
||||||
|
VITE_APP_NAME=KV-Graph
|
||||||
|
VITE_APP_VERSION=1.0.0
|
||||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE & Editor
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.eslintcache
|
||||||
|
*.local
|
||||||
|
Thumbs.db
|
||||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# 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 if needed (optional, using default for now but robust for SPA)
|
||||||
|
# We'll inline a simple config to handle SPA routing (try_files $uri /index.html)
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
index index.html index.htm; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
174
README.md
Normal file
174
README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
# 🔮 KV-Graph
|
||||||
|
|
||||||
|
**AI-Powered Diagram Editor** — Transform ideas into beautiful, interactive flowcharts using natural language, images, or Mermaid code.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **🤖 AI-Powered Generation** — Generates complex diagrams from text prompts using **Llama 3** (local browser) or Cloud AI.
|
||||||
|
- **<EFBFBD>️ Vision-to-Diagram** — **Florence-2** powered analysis converts screenshots and sketches into editable layouts entirely in the browser.
|
||||||
|
- **<EFBFBD>️ Unified Toolkit** — A clean, consolidated toolbar for critical actions (Zoom, Layout, Pan/Select) keeps the canvas "void-like".
|
||||||
|
- **🗺️ MiniMap Overlay** — Navigational aid for large diagrams, unobtrusively positioned in the bottom-right.
|
||||||
|
- **💡 Smart Guidance** — Context-aware tips and rotation suggestions when looking at empty space.
|
||||||
|
- **<EFBFBD> Theme-Aware Code Editor** — Monaco editor that automatically syncs with your Light/Dark aesthetic.
|
||||||
|
- **🎨 "Void" Aesthetic** — Premium glassmorphism design with deep blur effects and cinematic transitions.
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or pnpm
|
||||||
|
- WebGPU-compatible browser (Chrome 113+, Edge) for In-Browser AI
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/your-username/kv-graph.git
|
||||||
|
cd kv-graph
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
||||||
|
|
||||||
|
## <20> AI Configuration
|
||||||
|
|
||||||
|
KV-Graph supports a **Local-First** AI architecture, running powerful models directly in your browser via WebGPU.
|
||||||
|
|
||||||
|
### 🌐 In-Browser Mode (Privacy First)
|
||||||
|
Runs entirely on your device. No data leaves your machine.
|
||||||
|
|
||||||
|
| Capability | Model | Technology |
|
||||||
|
|------------|-------|------------|
|
||||||
|
| **Text Generation** | Llama-3-8B-Instruct | WebLLM (WebGPU) |
|
||||||
|
| **Vision Analysis** | Florence-2-base | Transformers.js (ONNX) |
|
||||||
|
|
||||||
|
*Note: First-time load requires downloading model weights (~4GB total).*
|
||||||
|
|
||||||
|
### ☁️ Cloud Mode (Optional)
|
||||||
|
Connect to external providers for enhanced capabilities.
|
||||||
|
|
||||||
|
| Provider | Model | API Key Required |
|
||||||
|
|----------|-------|------------------|
|
||||||
|
| OpenAI | GPT-4 Vision | ✅ |
|
||||||
|
| Google Gemini | Gemini Pro Vision | ✅ |
|
||||||
|
| Ollama | Custom | Local URL |
|
||||||
|
|
||||||
|
Configure your AI provider in **Settings** (⚙️ icon).
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
kv-graph/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ │ ├── nodes/ # Custom React Flow nodes
|
||||||
|
│ │ ├── edges/ # Custom React Flow edges
|
||||||
|
│ │ ├── editor/ # Monaco Code Editor
|
||||||
|
│ │ └── ui/ # UI Kit (Glassware)
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── lib/ # Core Logic
|
||||||
|
│ │ ├── aiService.ts # AI Orchestrator
|
||||||
|
│ │ ├── webLlmService.ts # Local LLM Engine
|
||||||
|
│ │ ├── visionService.ts # Local Vision Engine
|
||||||
|
│ │ └── layoutEngine.ts # Dagre Auto-Layout
|
||||||
|
│ ├── pages/ # Route pages
|
||||||
|
│ ├── store/ # Zustand Global State
|
||||||
|
│ │ ├── flowStore.ts # Combined Flow State
|
||||||
|
│ │ └── settingsStore.ts # AI & Theme Config
|
||||||
|
│ ├── styles/ # Tailwind Global Styles
|
||||||
|
│ └── types/ # TypeScript interfaces
|
||||||
|
├── public/ # Static assets & Models
|
||||||
|
└── Configuration files
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
| Category | Technology |
|
||||||
|
|----------|------------|
|
||||||
|
| Framework | React 19 + TypeScript |
|
||||||
|
| Build Tool | Vite 7 |
|
||||||
|
| Styling | Tailwind CSS 4 |
|
||||||
|
| AI Inference | **WebLLM** (WebGPU) + **Transformers.js** |
|
||||||
|
| Diagramming | React Flow (XY Flow) |
|
||||||
|
| Code Editor | Monaco Editor (Theme Aware) |
|
||||||
|
| State | Zustand |
|
||||||
|
| Icons | Lucide React |
|
||||||
|
|
||||||
|
## 📤 Export Formats
|
||||||
|
|
||||||
|
| Format | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **PNG** | High-resolution raster image (3x pixel ratio) |
|
||||||
|
| **JPG** | Compressed image format |
|
||||||
|
| **SVG** | Vector graphics (scalable) |
|
||||||
|
| **JSON** | Full diagram data (nodes, edges, metadata) |
|
||||||
|
| **Mermaid** | Mermaid.js code for use elsewhere |
|
||||||
|
| **React** | Complete React component with React Flow |
|
||||||
|
|
||||||
|
## ⌨️ Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Ctrl/⌘ + S` | Save diagram |
|
||||||
|
| `Ctrl/⌘ + E` | Export diagram |
|
||||||
|
| `Ctrl/⌘ + Z` | Undo |
|
||||||
|
| `Ctrl/⌘ + Shift + Z` | Redo |
|
||||||
|
| `Delete/Backspace` | Delete selected node |
|
||||||
|
| `Escape` | Deselect / Close panel |
|
||||||
|
|
||||||
|
## 🎨 Theming
|
||||||
|
|
||||||
|
KV-Graph features a premium glassmorphism design with:
|
||||||
|
|
||||||
|
- **Dark Mode** — Deep void backgrounds with subtle gradients
|
||||||
|
- **Light Mode** — Clean, minimal aesthetics
|
||||||
|
- **Glass Panels** — Frosted glass effects with blur
|
||||||
|
- **Smooth Animations** — Cinematic transitions
|
||||||
|
|
||||||
|
## 📜 Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start dev server with HMR
|
||||||
|
|
||||||
|
# Production
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run preview # Preview production build
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
- [ ] Undo/Redo history
|
||||||
|
- [ ] Real-time collaboration
|
||||||
|
- [ ] Custom node shapes
|
||||||
|
- [ ] Template library
|
||||||
|
- [ ] API for programmatic diagram generation
|
||||||
|
- [ ] Plugin system
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License — see [LICENSE](./LICENSE) for details.
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- [React Flow](https://reactflow.dev/) — Powerful diagram library
|
||||||
|
- [Mermaid.js](https://mermaid.js.org/) — Diagram syntax inspiration
|
||||||
|
- [Ollama](https://ollama.ai/) — Local AI inference
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) — Utility-first styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Made with ❤️ by <a href="https://github.com/your-username">Your Name</a>
|
||||||
|
</p>
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
webapp:
|
||||||
|
build: .
|
||||||
|
container_name: kv-graph-web
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama:latest
|
||||||
|
container_name: ollama-service
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "11434:11434"
|
||||||
|
volumes:
|
||||||
|
- ./ollama_data:/root/.ollama
|
||||||
|
environment:
|
||||||
|
- OLLAMA_KEEP_ALIVE=24h
|
||||||
|
# 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
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
21
index.html
Normal file
21
index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SysVis.AI - System Design Visualizer</title>
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||||
|
rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
8155
package-lock.json
generated
Normal file
8155
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
71
package.json
Normal file
71
package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
{
|
||||||
|
"name": "kv-graph",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI-Powered Diagram Editor - Transform ideas into beautiful, interactive flowcharts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist node_modules/.cache",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"diagram",
|
||||||
|
"flowchart",
|
||||||
|
"mermaid",
|
||||||
|
"react-flow",
|
||||||
|
"ai",
|
||||||
|
"visualization"
|
||||||
|
],
|
||||||
|
"author": "KV",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"mermaid": "^11.12.2",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.11.0",
|
||||||
|
"zustand": "^5.0.9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
21
scripts/init-nas-models.sh
Normal file
21
scripts/init-nas-models.sh
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "Checking if Ollama container is running with GPU support..."
|
||||||
|
|
||||||
|
# Check if container is up
|
||||||
|
if [ "$(docker ps -q -f name=ollama-service)" ]; then
|
||||||
|
echo "Ollama container found."
|
||||||
|
else
|
||||||
|
echo "Error: 'ollama-service' container is not running. Please run 'docker-compose up -d' first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Pulling models inside the container..."
|
||||||
|
echo "1. Pulling moondream (Vision)..."
|
||||||
|
docker exec ollama-service ollama pull moondream
|
||||||
|
|
||||||
|
echo "2. Pulling llama3 (Text Logic)..."
|
||||||
|
docker exec ollama-service ollama pull llama3
|
||||||
|
|
||||||
|
echo "Done! Models are ready."
|
||||||
|
echo "You can now select 'moondream' in the KV-Graph settings."
|
||||||
38
scripts/setup-ollama-models.ps1
Normal file
38
scripts/setup-ollama-models.ps1
Normal file
|
|
@ -0,0 +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
|
||||||
18
src/App.tsx
Normal file
18
src/App.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { Dashboard } from './pages/Dashboard';
|
||||||
|
import { Editor } from './pages/Editor';
|
||||||
|
import { History } from './pages/History';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/diagram" element={<Editor />} />
|
||||||
|
<Route path="/history" element={<History />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4 KiB |
372
src/components/CodeEditor.tsx
Normal file
372
src/components/CodeEditor.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import Editor from '@monaco-editor/react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { parseMermaid, detectInputType } from '../lib/mermaidParser';
|
||||||
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
import { interpretText, suggestFix } from '../lib/aiService';
|
||||||
|
import {
|
||||||
|
Loader2, Zap, Trash2, FileText, Lightbulb,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const SAMPLE_MERMAID = `flowchart TD
|
||||||
|
subgraph AI [AI Director]
|
||||||
|
A1[Analyze Trends]
|
||||||
|
A2[Generate Script]
|
||||||
|
A3[Create Draft]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Team [Intern Team]
|
||||||
|
B1[Fine-tune Ideas]
|
||||||
|
B2[Edit Content]
|
||||||
|
B3[Review & Approve]
|
||||||
|
end
|
||||||
|
|
||||||
|
A1 --> A2 --> A3
|
||||||
|
A3 --> B1
|
||||||
|
B1 --> B2 --> B3`;
|
||||||
|
|
||||||
|
export function CodeEditor() {
|
||||||
|
const [code, setCode] = useState<string>('');
|
||||||
|
const [inputType, setInputType] = useState<'mermaid' | 'natural'>('mermaid');
|
||||||
|
const [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]);
|
||||||
|
const [highlightedLine, setHighlightedLine] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Use any for editor refs since OnMount type isn't reliably exported
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
const monacoRef = useRef<any>(null);
|
||||||
|
const decorationsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
||||||
|
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, nodes, setSelectedNode, theme
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
// Listen for node click events from the canvas (bidirectional highlighting)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => {
|
||||||
|
if (!editorRef.current || !code) return;
|
||||||
|
|
||||||
|
const { label } = event.detail;
|
||||||
|
const lines = code.split('\n');
|
||||||
|
|
||||||
|
// Find line containing this node's label
|
||||||
|
const lineIndex = lines.findIndex(line =>
|
||||||
|
line.includes(label) ||
|
||||||
|
line.includes(`[${label}]`) ||
|
||||||
|
line.includes(`(${label})`) ||
|
||||||
|
line.includes(`{${label}}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lineIndex !== -1) {
|
||||||
|
setHighlightedLine(lineIndex + 1);
|
||||||
|
|
||||||
|
// Scroll to and highlight the line
|
||||||
|
editorRef.current.revealLineInCenter(lineIndex + 1);
|
||||||
|
|
||||||
|
// Add decoration
|
||||||
|
if (monacoRef.current) {
|
||||||
|
decorationsRef.current = editorRef.current.deltaDecorations(
|
||||||
|
decorationsRef.current,
|
||||||
|
[{
|
||||||
|
range: new monacoRef.current.Range(lineIndex + 1, 1, lineIndex + 1, 1),
|
||||||
|
options: {
|
||||||
|
isWholeLine: true,
|
||||||
|
className: 'highlighted-line',
|
||||||
|
glyphMarginClassName: 'highlighted-glyph',
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear highlight after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []);
|
||||||
|
setHighlightedLine(null);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('node-selected', handleNodeSelected as EventListener);
|
||||||
|
return () => window.removeEventListener('node-selected', handleNodeSelected as EventListener);
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
const handleCodeChange = useCallback((value: string | undefined) => {
|
||||||
|
const newCode = value || '';
|
||||||
|
setCode(newCode);
|
||||||
|
if (newCode.trim()) setInputType(detectInputType(newCode));
|
||||||
|
}, [inputType]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSyntaxErrors([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let mermaidCode = code;
|
||||||
|
let metadata: Record<string, any> | undefined;
|
||||||
|
|
||||||
|
if (inputType === 'natural') {
|
||||||
|
if (aiMode === 'offline' && !ollamaUrl) throw new Error('Configure Ollama URL in settings');
|
||||||
|
const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
|
||||||
|
if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed');
|
||||||
|
mermaidCode = result.mermaidCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceCode(mermaidCode);
|
||||||
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode);
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
parsedNodes.forEach(node => {
|
||||||
|
const label = (node.data.label as string) || '';
|
||||||
|
if (label && metadata && metadata[label]) node.data.metadata = metadata[label];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error processing code';
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Try to parse line number from error
|
||||||
|
const lineMatch = errorMessage.match(/line (\d+)/i);
|
||||||
|
if (lineMatch) {
|
||||||
|
setSyntaxErrors([{ line: parseInt(lineMatch[1]), message: errorMessage }]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [code, inputType, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setNodes, setEdges, setLoading, setError, setSourceCode]);
|
||||||
|
|
||||||
|
const [suggestion, setSuggestion] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSuggest = useCallback(async () => {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuggestion(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await suggestFix(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
|
||||||
|
if (result.success && result.mermaidCode) {
|
||||||
|
setCode(result.mermaidCode);
|
||||||
|
setSuggestion(result.explanation || 'Code improved');
|
||||||
|
setTimeout(() => setSuggestion(null), 5000);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Could not get suggestion');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Suggestion failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setLoading, setError]);
|
||||||
|
|
||||||
|
// Click on node ID in code to highlight on canvas
|
||||||
|
const handleEditorClick = useCallback(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
const position = editorRef.current.getPosition();
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
const line = editorRef.current.getModel()?.getLineContent(position.lineNumber);
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
// Extract node ID from line (e.g., "A1[Label]" -> find node with label "Label")
|
||||||
|
const labelMatch = line.match(/\[([^\]]+)\]/) || line.match(/\(([^)]+)\)/) || line.match(/\{([^}]+)\}/);
|
||||||
|
if (labelMatch) {
|
||||||
|
const label = labelMatch[1];
|
||||||
|
const matchingNode = nodes.find(n => (n.data.label as string)?.includes(label));
|
||||||
|
if (matchingNode) {
|
||||||
|
setSelectedNode(matchingNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodes, setSelectedNode]);
|
||||||
|
|
||||||
|
// Define themes once on mount, but update selection on theme change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!monacoRef.current) return;
|
||||||
|
|
||||||
|
// Define Dark Theme
|
||||||
|
monacoRef.current.editor.defineTheme('architect-dark', {
|
||||||
|
base: 'vs-dark',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'keyword', foreground: '60a5fa', fontStyle: 'bold' },
|
||||||
|
{ token: 'comment', foreground: '64748b', fontStyle: 'italic' },
|
||||||
|
{ token: 'string', foreground: '34d399' },
|
||||||
|
{ token: 'number', foreground: 'fbbf24' },
|
||||||
|
{ token: 'type', foreground: 'c084fc' },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#0f172a',
|
||||||
|
'editor.foreground': '#e2e8f0',
|
||||||
|
'editorLineNumber.foreground': '#475569',
|
||||||
|
'editorLineNumber.activeForeground': '#60a5fa',
|
||||||
|
'editor.lineHighlightBackground': '#1e293b',
|
||||||
|
'editor.selectionBackground': '#334155',
|
||||||
|
'editorCursor.foreground': '#60a5fa',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Define Light Theme
|
||||||
|
monacoRef.current.editor.defineTheme('architect-light', {
|
||||||
|
base: 'vs',
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: 'keyword', foreground: '2563eb', fontStyle: 'bold' }, // Blue-600
|
||||||
|
{ token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, // Slate-400
|
||||||
|
{ token: 'string', foreground: '059669' }, // Emerald-600
|
||||||
|
{ token: 'number', foreground: 'd97706' }, // Amber-600
|
||||||
|
{ token: 'type', foreground: '9333ea' }, // Purple-600
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': '#f8fafc', // Slate-50
|
||||||
|
'editor.foreground': '#334155', // Slate-700
|
||||||
|
'editorLineNumber.foreground': '#cbd5e1', // Slate-300
|
||||||
|
'editorLineNumber.activeForeground': '#2563eb', // Blue-600
|
||||||
|
'editor.lineHighlightBackground': '#f1f5f9', // Slate-100
|
||||||
|
'editor.selectionBackground': '#e2e8f0', // Slate-200
|
||||||
|
'editorCursor.foreground': '#2563eb', // Blue-600
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const handleEditorMount = useCallback((editor: any, monaco: any) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
monacoRef.current = monaco;
|
||||||
|
// Initial theme set handled by useEffect
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-4 animate-slide-up">
|
||||||
|
{/* Editor Container with Badges */}
|
||||||
|
<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={handleCodeChange}
|
||||||
|
onMount={handleEditorMount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating Action Buttons */}
|
||||||
|
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||||
|
<button
|
||||||
|
onClick={() => setCode(SAMPLE_MERMAID)}
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 text-slate-400 hover:text-white transition-all backdrop-blur-md shadow-lg"
|
||||||
|
title="Load Sample"
|
||||||
|
>
|
||||||
|
<FileText className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCode('')}
|
||||||
|
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
|
||||||
|
title="Clear"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Syntax Errors */}
|
||||||
|
{syntaxErrors.length > 0 && (
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
|
||||||
|
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Suggestion Toast */}
|
||||||
|
{suggestion && (
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl animate-fade-in z-20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Lightbulb className="w-4 h-4 text-blue-400 shrink-0" />
|
||||||
|
<p className="text-[11px] text-blue-200 font-medium leading-relaxed">{suggestion}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSuggest}
|
||||||
|
disabled={!code.trim() || isLoading}
|
||||||
|
className="flex-1 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lightbulb className="w-4 h-4 text-slate-400 group-hover:text-yellow-400 transition-colors" />
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider">AI Fix</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!code.trim() || isLoading}
|
||||||
|
className="flex-[1.5] py-4 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white shadow-lg shadow-indigo-900/30 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-white/70" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 text-white" />
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider">Visualize</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/components/FlowCanvas.tsx
Normal file
361
src/components/FlowCanvas.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Panel,
|
||||||
|
MiniMap,
|
||||||
|
useReactFlow,
|
||||||
|
BackgroundVariant,
|
||||||
|
SelectionMode,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { nodeTypes } from './nodes/CustomNodes';
|
||||||
|
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
|
||||||
|
import {
|
||||||
|
Spline, Minus, Plus, Maximize, Map, Wand2,
|
||||||
|
RotateCcw, Download, Command, Hand, MousePointer2, Settings2, ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
|
||||||
|
export function FlowCanvas() {
|
||||||
|
const {
|
||||||
|
nodes, edges, onNodesChange, onEdgesChange, onConnect,
|
||||||
|
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
|
||||||
|
setNodes, setEdges
|
||||||
|
} = useFlowStore();
|
||||||
|
const { zoomIn, zoomOut, fitView, getViewport } = useReactFlow();
|
||||||
|
const [showToolkit, setShowToolkit] = useState(false); // New State
|
||||||
|
const [showMiniMap, setShowMiniMap] = useState(true);
|
||||||
|
const [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select
|
||||||
|
|
||||||
|
const nodeTypesMemo = useMemo(() => nodeTypes, []);
|
||||||
|
const edgeTypesMemo = useMemo(() => edgeTypes, []);
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
// Helper components for Toolkit
|
||||||
|
function ToolkitButton({ icon: Icon, onClick, label }: { icon: any; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex-1 flex flex-col items-center justify-center py-2 hover:bg-white dark:hover:bg-white/10 transition-colors group"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-slate-500 dark:text-slate-400 group-hover:text-blue-600 dark:group-hover:text-blue-400 mb-0.5" />
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 dark:text-slate-500 group-hover:text-slate-600 dark:group-hover:text-slate-300">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuButton({ icon: Icon, label, active, onClick, iconClass = '' }: { icon: any; label: string; active?: boolean; onClick: () => void; iconClass?: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-all text-left ${active
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-slate-600 dark:text-secondary hover:bg-slate-100 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${active ? 'text-blue-600 dark:text-blue-400' : 'text-slate-400 dark:text-slate-500'} ${iconClass}`} />
|
||||||
|
<span className="text-xs font-bold">{label}</span>
|
||||||
|
{active && <div className="ml-auto w-1.5 h-1.5 rounded-full bg-blue-500" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control Button Component - No longer needed in original form but kept if referenced elsewhere
|
||||||
|
// or can be removed if unused. for now I will comment it out or leave it if TypeScript complains about usage elsewhere.
|
||||||
|
// But we replaced all usage in this file.
|
||||||
|
|
||||||
|
|
||||||
|
// Filter nodes based on active filters
|
||||||
|
const filteredNodes = useMemo(() => {
|
||||||
|
return nodes.map(node => {
|
||||||
|
if (node.type === 'group') return node;
|
||||||
|
|
||||||
|
const category = (node.data?.category as string) || (node.type === 'database' ? 'filter-db' : node.type === 'client' ? 'filter-client' : 'filter-server');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
hidden: !activeFilters.includes(category)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [nodes, activeFilters]);
|
||||||
|
|
||||||
|
// Apply edge styling with offsets to prevent overlaps
|
||||||
|
const styledEdges = useMemo(() => {
|
||||||
|
// Group edges by source-target pair to add offsets
|
||||||
|
const edgeGroups: Record<string, number> = {};
|
||||||
|
|
||||||
|
return edges.map((edge) => {
|
||||||
|
const key = `${edge.source}-${edge.target}`;
|
||||||
|
const reverseKey = `${edge.target}-${edge.source}`;
|
||||||
|
|
||||||
|
// Count how many edges share this connection
|
||||||
|
const groupIndex = edgeGroups[key] || edgeGroups[reverseKey] || 0;
|
||||||
|
edgeGroups[key] = groupIndex + 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
type: edgeStyle === 'curved' ? 'curved' : 'straight',
|
||||||
|
// Add slight offset for parallel edges
|
||||||
|
style: {
|
||||||
|
...edge.style,
|
||||||
|
strokeWidth: 2,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...edge.data,
|
||||||
|
offset: groupIndex * 20, // Offset parallel edges
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [edges, edgeStyle]);
|
||||||
|
|
||||||
|
// Filter edges to only show connections between visible nodes
|
||||||
|
const filteredEdges = useMemo(() => {
|
||||||
|
const visibleNodeIds = new Set(filteredNodes.filter(n => !n.hidden).map(n => n.id));
|
||||||
|
return styledEdges.map(edge => ({
|
||||||
|
...edge,
|
||||||
|
hidden: !visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)
|
||||||
|
}));
|
||||||
|
}, [styledEdges, filteredNodes]);
|
||||||
|
|
||||||
|
// Node click handler - bidirectional highlighting
|
||||||
|
const onNodeClick = useCallback((_event: React.MouseEvent, node: any) => {
|
||||||
|
setSelectedNode(node);
|
||||||
|
// Dispatch event for code editor cross-highlighting
|
||||||
|
window.dispatchEvent(new CustomEvent('node-selected', { detail: { nodeId: node.id, label: node.data?.label } }));
|
||||||
|
}, [setSelectedNode]);
|
||||||
|
|
||||||
|
const onPaneClick = useCallback(() => {
|
||||||
|
setSelectedNode(null);
|
||||||
|
}, [setSelectedNode]);
|
||||||
|
|
||||||
|
// Auto-layout cleanup function
|
||||||
|
const handleAutoLayout = useCallback(() => {
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(nodes, edges);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
setTimeout(() => fitView({ padding: 0.2, duration: 500 }), 100);
|
||||||
|
}, [nodes, edges, setNodes, setEdges, fitView]);
|
||||||
|
|
||||||
|
// Reset view
|
||||||
|
const handleResetView = useCallback(() => {
|
||||||
|
fitView({ padding: 0.2, duration: 500 });
|
||||||
|
}, [fitView]);
|
||||||
|
|
||||||
|
// MiniMap node color function
|
||||||
|
const miniMapNodeColor = useCallback((node: any) => {
|
||||||
|
const label = (node.data?.label || '').toLowerCase();
|
||||||
|
if (label.includes('ai') || label.includes('director')) return '#8b5cf6';
|
||||||
|
if (label.includes('intern') || label.includes('team')) return '#f59e0b';
|
||||||
|
if (label.includes('data') || label.includes('analyst')) return '#06b6d4';
|
||||||
|
if (label.includes('platform') || label.includes('shop')) return '#ec4899';
|
||||||
|
return '#3b82f6';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative bg-void">
|
||||||
|
<EdgeDefs />
|
||||||
|
<ReactFlow
|
||||||
|
nodes={filteredNodes}
|
||||||
|
edges={filteredEdges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeClick={onNodeClick}
|
||||||
|
onPaneClick={onPaneClick}
|
||||||
|
nodeTypes={nodeTypesMemo}
|
||||||
|
edgeTypes={edgeTypesMemo}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2, maxZoom: 1.5 }}
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={4}
|
||||||
|
panOnDrag={!isSelectionMode}
|
||||||
|
selectionOnDrag={isSelectionMode}
|
||||||
|
selectionMode={SelectionMode.Partial}
|
||||||
|
selectionKeyCode="Shift"
|
||||||
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
defaultEdgeOptions={{
|
||||||
|
type: edgeStyle === 'curved' ? 'curved' : 'straight',
|
||||||
|
style: { strokeWidth: 2, stroke: theme === 'dark' ? '#475569' : '#94a3b8' },
|
||||||
|
}}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
{/* Grid Background */}
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
color={theme === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Control Panel - Top Right (Unified Toolkit) */}
|
||||||
|
<Panel position="top-right" className="!m-4 flex flex-col items-end gap-3 z-50">
|
||||||
|
<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'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* MiniMap Container - Bottom Right */}
|
||||||
|
<Panel position="bottom-right" className="!m-4 z-40">
|
||||||
|
{showMiniMap && nodes.length > 0 && (
|
||||||
|
<div className="w-52 h-36 rounded-xl border border-slate-200 dark:border-white/10 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={miniMapNodeColor}
|
||||||
|
nodeStrokeWidth={3}
|
||||||
|
nodeBorderRadius={8}
|
||||||
|
maskColor={theme === 'dark' ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'}
|
||||||
|
className="!w-full !h-full !m-0 !relative !bg-transparent"
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Status Indicator - Bottom Left */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<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">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-tertiary">
|
||||||
|
{nodes.filter(n => n.type !== 'group').length} Nodes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-3 bg-slate-200 dark:bg-white/10" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-tertiary">
|
||||||
|
{edges.length} Edges
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Command Palette Hint - Top Center */}
|
||||||
|
<Panel position="top-center" className="!mt-6">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/60 dark:bg-surface/60 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-lg text-slate-500 dark:text-tertiary hover:text-slate-800 dark:hover:text-secondary transition-colors cursor-pointer opacity-60 hover:opacity-100">
|
||||||
|
<Command className="w-3 h-3" />
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider">K</span>
|
||||||
|
<span className="text-[9px] text-slate-400 dark:text-slate-500">Quick Actions</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control Button Component
|
||||||
|
interface ControlButtonProps {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlButton({ icon: Icon, onClick, title, highlight }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
className={`
|
||||||
|
w-10 h-10 flex items-center justify-center
|
||||||
|
text-secondary hover:text-primary hover:bg-blue-500/10
|
||||||
|
transition-all
|
||||||
|
${highlight ? 'text-blue-500' : ''}
|
||||||
|
`}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/ImageUpload.tsx
Normal file
184
src/components/ImageUpload.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { analyzeImage, analyzeSVG } from '../lib/aiService';
|
||||||
|
import { parseMermaid } from '../lib/mermaidParser';
|
||||||
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
import { Upload, X, Loader2, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
export function ImageUpload() {
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [fileType, setFileType] = useState<'image' | 'svg' | null>(null);
|
||||||
|
const [svgContent, setSvgContent] = useState<string>('');
|
||||||
|
|
||||||
|
const {
|
||||||
|
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
|
||||||
|
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
const handleFile = useCallback((file: File) => {
|
||||||
|
const validImageTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const isSvg = file.type === 'image/svg+xml' || file.name.endsWith('.svg');
|
||||||
|
|
||||||
|
if (!validImageTypes.includes(file.type) && !isSvg) {
|
||||||
|
setError('Please upload a JPG, PNG, WEBP, or SVG file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (isSvg) {
|
||||||
|
setFileType('svg');
|
||||||
|
const textReader = new FileReader();
|
||||||
|
textReader.onload = (e) => setSvgContent(e.target?.result as string);
|
||||||
|
textReader.readAsText(file);
|
||||||
|
|
||||||
|
const previewReader = new FileReader();
|
||||||
|
previewReader.onload = (e) => setPreview(e.target?.result as string);
|
||||||
|
previewReader.readAsDataURL(file);
|
||||||
|
} else {
|
||||||
|
setFileType('image');
|
||||||
|
setSvgContent('');
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => setPreview(e.target?.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}, [setError]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}, [handleFile]);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => setIsDragging(false), []);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
}, [handleFile]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (aiMode === 'offline' && !ollamaUrl) {
|
||||||
|
setError('Please configure Ollama URL in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (fileType === 'svg' && svgContent) {
|
||||||
|
result = await analyzeSVG(
|
||||||
|
svgContent,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
generationComplexity
|
||||||
|
);
|
||||||
|
} else if (preview) {
|
||||||
|
result = await analyzeImage(
|
||||||
|
preview,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
generationComplexity
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('No content to process');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.success || !result.mermaidCode) {
|
||||||
|
throw new Error(result.error || 'Could not interpret flow from the input');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceCode(result.mermaidCode);
|
||||||
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
|
||||||
|
|
||||||
|
if (result.metadata) {
|
||||||
|
parsedNodes.forEach(node => {
|
||||||
|
const label = (node.data.label as string) || '';
|
||||||
|
if (label && result.metadata && result.metadata[label]) {
|
||||||
|
node.data.metadata = result.metadata[label];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to process input');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [preview, svgContent, fileType, ollamaUrl, modelName, setNodes, setEdges, setLoading, setError, setSourceCode, aiMode, onlineProvider, apiKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-6 animate-slide-up">
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onClick={() => !preview && document.getElementById('image-input')?.click()}
|
||||||
|
className={`flex-1 relative rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all border border-dashed
|
||||||
|
${isDragging
|
||||||
|
? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]'
|
||||||
|
: 'bg-black/20 border-white/5 hover:bg-black/40 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="image-input"
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.webp,.svg"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<div className="w-full h-full p-4 relative group">
|
||||||
|
<img src={preview} alt="Preview" className="w-full h-full object-contain rounded-xl" />
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setPreview(null); }}
|
||||||
|
className="absolute top-6 right-6 w-8 h-8 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-red-500 transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-6">
|
||||||
|
<Upload className="w-8 h-8 text-slate-700 mx-auto mb-4" />
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-600">
|
||||||
|
{isDragging ? 'Release' : 'Drop Visuals'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={(!preview && !svgContent) || isLoading}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-void-950" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-4 h-4 fill-void-950" />
|
||||||
|
<span>Reconstruct</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
src/components/InputPanel.tsx
Normal file
175
src/components/InputPanel.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { ImageUpload } from './ImageUpload';
|
||||||
|
import { CodeEditor } from './CodeEditor';
|
||||||
|
import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { interpretText } from '../lib/aiService';
|
||||||
|
import { parseMermaid } from '../lib/mermaidParser';
|
||||||
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
|
||||||
|
type Tab = 'image' | 'code' | 'describe';
|
||||||
|
|
||||||
|
export function InputPanel() {
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('image');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const {
|
||||||
|
setNodes, setEdges, setLoading, setError,
|
||||||
|
setSourceCode, isLoading, ollamaUrl, modelName,
|
||||||
|
aiMode, onlineProvider, apiKey,
|
||||||
|
generationComplexity, setGenerationComplexity
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
const handleTextGenerate = useCallback(async () => {
|
||||||
|
if (!description.trim()) return;
|
||||||
|
if (aiMode === 'offline' && !ollamaUrl) {
|
||||||
|
setError('Please configure Ollama URL in settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await interpretText(
|
||||||
|
description,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
generationComplexity
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success || !result.mermaidCode) {
|
||||||
|
throw new Error(result.error || 'Could not interpret flow from description');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceCode(result.mermaidCode);
|
||||||
|
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
|
||||||
|
|
||||||
|
if (result.metadata) {
|
||||||
|
parsedNodes.forEach(node => {
|
||||||
|
const label = (node.data.label as string) || '';
|
||||||
|
if (label && result.metadata && result.metadata[label]) {
|
||||||
|
node.data.metadata = result.metadata[label];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error instanceof Error ? error.message : 'Failed to process description');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode]);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'image' as Tab, icon: Image, label: 'Upload' },
|
||||||
|
{ id: 'code' as Tab, icon: Code, label: 'Code' },
|
||||||
|
{ id: 'describe' as Tab, icon: MessageSquare, label: 'Describe' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Floating Tabs */}
|
||||||
|
<div className="px-4 pt-6 pb-2">
|
||||||
|
<div className="flex bg-slate-100 dark:bg-black/20 p-1 rounded-full border border-black/5 dark:border-white/5 mx-auto max-w-[280px]">
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = activeTab === tab.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-full transition-all duration-300 relative
|
||||||
|
${isActive ? 'text-white' : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute inset-0 bg-blue-600 dark:bg-blue-500 rounded-full shadow-lg shadow-blue-500/25 animate-fade-in" />
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center gap-2 z-10">
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-wider">{tab.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Complexity Toggle */}
|
||||||
|
{(activeTab === 'image' || activeTab === 'describe') && (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 pl-1 mb-2 block">Diagram Complexity</label>
|
||||||
|
<div className="flex items-center gap-2 p-1.5 rounded-xl bg-slate-100 dark:bg-black/30 border border-black/5 dark:border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setGenerationComplexity('simple')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all
|
||||||
|
${generationComplexity === 'simple'
|
||||||
|
? 'bg-gradient-to-r from-blue-600 to-blue-500 text-white shadow-lg shadow-blue-900/30'
|
||||||
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">⚡</span>
|
||||||
|
Simple
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setGenerationComplexity('complex')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-[11px] font-bold uppercase tracking-wider transition-all
|
||||||
|
${generationComplexity === 'complex'
|
||||||
|
? 'bg-gradient-to-r from-indigo-600 to-purple-500 text-white shadow-lg shadow-indigo-900/30'
|
||||||
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-base">🔮</span>
|
||||||
|
Complex
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 px-4 pb-6 overflow-y-auto hide-scrollbar">
|
||||||
|
{activeTab === 'image' && <ImageUpload />}
|
||||||
|
{activeTab === 'code' && <CodeEditor />}
|
||||||
|
{activeTab === 'describe' && (
|
||||||
|
<div className="h-full flex flex-col animate-slide-up">
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe your diagram in natural language...
|
||||||
|
|
||||||
|
Example: Create a user registration flow with login, verification, and dashboard access"
|
||||||
|
className="w-full flex-1 p-5 rounded-2xl bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 text-slate-800 dark:text-slate-200 text-sm resize-none outline-none focus:border-blue-500/40 focus:ring-2 focus:ring-blue-500/20 transition-all font-sans leading-relaxed placeholder:text-slate-400 dark:placeholder:text-slate-600"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleTextGenerate}
|
||||||
|
disabled={!description.trim() || isLoading}
|
||||||
|
className={`mt-4 w-full py-4 rounded-xl font-bold text-sm tracking-wide transition-all duration-300 flex items-center justify-center gap-3
|
||||||
|
${!description.trim() || isLoading
|
||||||
|
? 'bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-blue-600 via-blue-500 to-cyan-500 text-white shadow-xl shadow-blue-900/30 hover:shadow-blue-500/40 hover:scale-[1.02] active:scale-[0.98]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
<span>Generating...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="w-5 h-5" />
|
||||||
|
<span>Generate Diagram</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/InteractiveLegend.tsx
Normal file
59
src/components/InteractiveLegend.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Eye, Server, Database, Smartphone } from 'lucide-react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' },
|
||||||
|
{ id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' },
|
||||||
|
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InteractiveLegend() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { focusMode, activeFilters, toggleFilter } = useFlowStore();
|
||||||
|
|
||||||
|
if (focusMode) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-20 left-4 z-50">
|
||||||
|
{/* Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-all shadow-lg border ${isOpen
|
||||||
|
? 'bg-blue-600 text-white border-blue-500'
|
||||||
|
: 'bg-surface text-secondary titanium-border hover:text-primary hover:border-blue-500/30'
|
||||||
|
}`}
|
||||||
|
title="Diagram Legend"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute bottom-12 left-0 floating-glass border titanium-border rounded-xl p-3 min-w-[140px] shadow-xl animate-fade-in">
|
||||||
|
<div className="text-[10px] font-bold uppercase tracking-widest text-tertiary mb-3 border-b titanium-border pb-2">
|
||||||
|
Legend Filters
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filters.map(f => {
|
||||||
|
const isActive = activeFilters.includes(f.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => toggleFilter(f.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-2 py-2 rounded-lg transition-all ${isActive ? 'bg-blue-500/10 text-primary' : 'opacity-40 hover:opacity-60 grayscale'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: f.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] font-bold tracking-tight">{f.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/NodeDetailsPanel.tsx
Normal file
207
src/components/NodeDetailsPanel.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import {
|
||||||
|
Server, Database, Smartphone, Layers, X, Tag, Code2,
|
||||||
|
FileText, Activity, Zap, Cpu, Wifi, BarChart3
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { VisualOrganizerPanel } from './VisualOrganizerPanel';
|
||||||
|
import { SmartGuide } from './SmartGuide';
|
||||||
|
|
||||||
|
export function NodeDetailsPanel() {
|
||||||
|
const { selectedNode, setSelectedNode, nodes, edges, updateNodeData, updateNodeType, deleteNode } = useFlowStore();
|
||||||
|
|
||||||
|
// Stats for Empty State
|
||||||
|
const systemStats = useMemo(() => ({
|
||||||
|
activeNodes: nodes.length,
|
||||||
|
totalLinks: edges.length,
|
||||||
|
neuralLoad: '94%',
|
||||||
|
uptime: '99.9%',
|
||||||
|
engine: 'v3.0.4'
|
||||||
|
}), [nodes, edges]);
|
||||||
|
|
||||||
|
if (!selectedNode) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col p-8">
|
||||||
|
<div className="flex items-center gap-3 mb-10">
|
||||||
|
<Activity className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.4em] text-secondary">Live Statistics</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StatItem icon={Cpu} label="Active Nodes" value={systemStats.activeNodes} />
|
||||||
|
<StatItem icon={Zap} label="Neural Load" value={systemStats.neuralLoad} />
|
||||||
|
<StatItem icon={Wifi} label="Connectivity" value="Optimal" color="text-emerald-500" />
|
||||||
|
<StatItem icon={BarChart3} label="Total Links" value={systemStats.totalLinks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 mb-6">
|
||||||
|
<div className="w-full h-px bg-gradient-to-r from-transparent via-blue-500/20 to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VisualOrganizerPanel />
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<SmartGuide />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (selectedNode.data?.label as string) || 'Node';
|
||||||
|
const metadata = selectedNode.data?.metadata as { role?: string; techStack?: string[]; description?: string } | undefined;
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
const type = selectedNode.type || 'default';
|
||||||
|
if (type.includes('database')) return <Database className="w-5 h-5" />;
|
||||||
|
if (type.includes('client')) return <Smartphone className="w-5 h-5" />;
|
||||||
|
if (type.includes('cache')) return <Layers className="w-5 h-5" />;
|
||||||
|
return <Server className="w-5 h-5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col animate-slide-up">
|
||||||
|
<div className="p-6 pb-4 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.4em] text-slate-500 dark:text-secondary">Edit Node</span>
|
||||||
|
<button onClick={() => setSelectedNode(null)} className="text-slate-400 dark:text-secondary hover:text-slate-700 dark:hover:text-primary transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto hide-scrollbar space-y-6">
|
||||||
|
{/* Header Info */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-blue-50 dark:bg-blue-600/10 flex items-center justify-center text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-500/20 shrink-0">
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-tertiary">{selectedNode.type || 'Standard'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable Label */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-slate-500 dark:text-tertiary block">Node Label</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
defaultValue={label}
|
||||||
|
onChange={(e) => updateNodeData(selectedNode.id, { label: e.target.value })}
|
||||||
|
className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary placeholder:text-slate-400 dark:placeholder:text-slate-600"
|
||||||
|
placeholder="Enter label..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node Type Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[9px] font-black uppercase tracking-widest text-slate-500 dark:text-tertiary block">Node Type</label>
|
||||||
|
<select
|
||||||
|
value={selectedNode.type || 'default'}
|
||||||
|
onChange={(e) => updateNodeType(selectedNode.id, e.target.value)}
|
||||||
|
className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary cursor-pointer appearance-none"
|
||||||
|
>
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="start">Start</option>
|
||||||
|
<option value="end">End</option>
|
||||||
|
<option value="decision">Decision</option>
|
||||||
|
<option value="database">Database</option>
|
||||||
|
<option value="process">Process</option>
|
||||||
|
<option value="client">Client</option>
|
||||||
|
<option value="server">Server</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Metadata */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{metadata?.role && (
|
||||||
|
<InfoSection icon={Tag} label="Role" value={metadata.role} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata?.techStack && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="w-3.5 h-3.5 text-slate-400 dark:text-tertiary" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-widest text-slate-500 dark:text-secondary">Tech Stack</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{metadata.techStack.map((tech, i) => (
|
||||||
|
<span key={i} className="text-[10px] font-bold px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-black/20 text-slate-600 dark:text-secondary border border-slate-200 dark:border-white/5">
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Specification Section */}
|
||||||
|
<div className="space-y-3 pt-4 border-t border-slate-200 dark:border-white/5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-3.5 h-3.5 text-slate-400 dark:text-tertiary" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-widest text-slate-500 dark:text-secondary">Specifications</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SpecRow label="Node ID" value={selectedNode.id} />
|
||||||
|
<SpecRow label="Position" value={`${Math.round(selectedNode.position.x)}, ${Math.round(selectedNode.position.y)}`} />
|
||||||
|
<SpecRow label="Dimensions" value={`${selectedNode.width || '---'} x ${selectedNode.height || '---'}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metadata?.description && (
|
||||||
|
<div className="pt-4 border-t border-slate-200 dark:border-white/5">
|
||||||
|
<InfoSection icon={FileText} label="Instructional Description" value={metadata.description} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className="pt-4 border-t border-slate-200 dark:border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteNode(selectedNode.id)}
|
||||||
|
className="w-full py-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 text-[11px] font-bold uppercase tracking-wider hover:bg-red-100 dark:hover:bg-red-500/20 hover:border-red-300 dark:hover:border-red-500/40 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Delete Node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ icon: Icon, label, value, color = "text-slate-800 dark:text-primary" }: { icon: any; label: string; value: string | number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-surface border border-slate-200 dark:border-white/5 group-hover:bg-blue-500/10 transition-colors">
|
||||||
|
<Icon className="w-3 h-3 text-slate-400 dark:text-tertiary" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-bold text-slate-500 dark:text-tertiary uppercase tracking-widest">{label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-black ${color}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpecRow({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">{label}</span>
|
||||||
|
<span className="text-[10px] font-mono text-tertiary truncate max-w-[120px]">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoSection({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-3.5 h-3.5 text-tertiary" />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-widest text-secondary">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium leading-relaxed text-secondary italic">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/OnboardingTour.tsx
Normal file
180
src/components/OnboardingTour.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function OnboardingTour() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
title: "Visualize Instantly",
|
||||||
|
description: "Upload your existing architecture diagrams or paste a text description to begin analysis. Our system automatically parses the input to generate editable nodes.",
|
||||||
|
features: ["Supports PNG, JPG, PDF", "Natural Language Input"],
|
||||||
|
icon: "cloud_upload",
|
||||||
|
color: "primary", // blue-500
|
||||||
|
badges: [
|
||||||
|
{ icon: "code", label: "Code", color: "text-emerald-400" },
|
||||||
|
{ icon: "image", label: "PNG", color: "text-amber-400" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
title: "Interactive Editing",
|
||||||
|
description: "Double-click any node to edit its properties. Drag to reconnect, and use the intelligent layout engine to organize your system flow automatically.",
|
||||||
|
features: ["Smart Auto-Layout", "Real-time Validation"],
|
||||||
|
icon: "edit_square",
|
||||||
|
color: "accent-purple",
|
||||||
|
badges: [
|
||||||
|
{ icon: "auto_fix_high", label: "Auto", color: "text-purple-400" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
title: "Deep Dive Analysis",
|
||||||
|
description: "Click on any component to see AI-generated insights, including technology stack recommendations, potential bottlenecks, and security considerations.",
|
||||||
|
features: ["Tech Stack Inference", "Security Scanning"],
|
||||||
|
icon: "analytics",
|
||||||
|
color: "accent-teal",
|
||||||
|
badges: [
|
||||||
|
{ icon: "psychology", label: "AI", color: "text-teal-400" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
title: "Export & Share",
|
||||||
|
description: "Export your verified architecture as a PNG image, Mermaid code, or even a clean React component code to use directly in your documentation.",
|
||||||
|
features: ["Mermaid / JSON", "React Component"],
|
||||||
|
icon: "ios_share",
|
||||||
|
color: "accent-blue",
|
||||||
|
badges: [
|
||||||
|
{ icon: "download", label: "Export", color: "text-blue-400" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seen = localStorage.getItem('sysvis_onboarding_seen');
|
||||||
|
if (!seen) {
|
||||||
|
const timer = setTimeout(() => setIsOpen(true), 1000); // Delay for effect
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
localStorage.setItem('sysvis_onboarding_seen', 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < STEPS.length - 1) {
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
|
} else {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setCurrentStep(prev => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const step = STEPS[currentStep];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm animate-[fadeIn_0.3s_ease-out]">
|
||||||
|
{/* Modal Card Container */}
|
||||||
|
<div className="w-full max-w-[960px] bg-surface-dark rounded-2xl shadow-[0_20px_60px_-15px_rgba(0,0,0,0.5)] border border-border-dark flex flex-col md:flex-row overflow-hidden max-h-[90vh]">
|
||||||
|
|
||||||
|
{/* Left Side: Visual/Illustration Area */}
|
||||||
|
<div className="w-full md:w-5/12 bg-gradient-to-br from-[#161821] to-[#0e0f14] relative flex items-center justify-center p-8 md:p-12 min-h-[300px] md:min-h-full border-b md:border-b-0 md:border-r border-border-dark group overflow-hidden">
|
||||||
|
{/* Decorative Elements */}
|
||||||
|
<div className="absolute inset-0 bg-grid-pattern opacity-5 mix-blend-overlay"></div>
|
||||||
|
<div className={`absolute top-0 right-0 w-64 h-64 bg-${step.color}/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2 transition-colors duration-500`}></div>
|
||||||
|
|
||||||
|
{/* Main Illustration */}
|
||||||
|
<div className="relative z-10 flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Ripple Effects with dynamic color */}
|
||||||
|
<div className={`absolute inset-0 bg-${step.color}/20 rounded-full scale-150 animate-pulse transition-colors duration-500`}></div>
|
||||||
|
<div className={`absolute inset-0 bg-${step.color}/10 rounded-full scale-[2] animate-[pulse_2s_ease-in-out_infinite_0.5s] transition-colors duration-500`}></div>
|
||||||
|
|
||||||
|
{/* Icon Circle */}
|
||||||
|
<div className={`size-24 rounded-full bg-gradient-to-tr from-${step.color} to-blue-600 flex items-center justify-center shadow-[0_0_30px_rgba(43,75,238,0.4)] relative z-10 transition-colors duration-500`}>
|
||||||
|
<span className="material-icons-round text-white text-5xl">{step.icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Badges */}
|
||||||
|
{step.badges.map((badge, idx) => (
|
||||||
|
<div key={idx} className={`absolute ${idx === 0 ? '-right-8 -top-4' : '-left-8 -bottom-4'} bg-surface-dark border border-border-dark px-3 py-1.5 rounded-lg shadow-lg flex items-center gap-2 animate-[bounce_3s_infinite_${idx * 0.5}s]`}>
|
||||||
|
<span className={`material-icons-round ${badge.color} text-sm`}>{badge.icon}</span>
|
||||||
|
<span className="text-xs font-bold text-gray-200">{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Content & Controls */}
|
||||||
|
<div className="w-full md:w-7/12 flex flex-col justify-between p-6 md:p-10 md:py-12 bg-surface-dark">
|
||||||
|
{/* Header (Skip) */}
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
{/* Progress Indicators */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{STEPS.map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setCurrentStep(idx)}
|
||||||
|
className={`h-1.5 rounded-full transition-all duration-300 cursor-pointer ${idx === currentStep ? 'w-8 bg-primary' : 'w-2 bg-[#3b3f54] hover:bg-white/20'}`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-text-secondary hover:text-white text-sm font-bold flex items-center gap-1 transition-colors">
|
||||||
|
Skip
|
||||||
|
<span className="material-icons-round text-lg">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Text Content */}
|
||||||
|
<div className="flex flex-col gap-4 mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 self-start bg-primary/10 border border-primary/20 px-3 py-1 rounded-full">
|
||||||
|
<span className="text-primary text-xs font-bold uppercase tracking-wider">Step {currentStep + 1} of {STEPS.length}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-3xl font-bold text-white leading-tight font-display">{step.title}</h1>
|
||||||
|
<p className="text-lg text-text-secondary leading-relaxed max-w-md font-sans">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4 mt-2">
|
||||||
|
{step.features.map((feature, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-sm text-[#9da1b9]">
|
||||||
|
<span className="material-icons-round text-primary text-base">check_circle</span>
|
||||||
|
<span>{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-6 border-t border-border-dark mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className={`text-text-secondary hover:text-white font-medium text-sm flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-white/5 transition-all ${currentStep === 0 ? 'opacity-0 pointer-events-none' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="material-icons-round text-lg">arrow_back</span>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="flex min-w-[140px] cursor-pointer items-center justify-center overflow-hidden rounded-lg h-12 px-6 bg-primary hover:bg-blue-600 transition-colors text-white text-base font-bold leading-normal tracking-[0.015em] shadow-[0_4px_14px_rgba(43,75,238,0.4)] hover:shadow-[0_6px_20px_rgba(43,75,238,0.6)] hover:-translate-y-0.5 transform"
|
||||||
|
>
|
||||||
|
<span className="mr-2">{currentStep === STEPS.length - 1 ? 'Get Started' : 'Next Step'}</span>
|
||||||
|
<span className="material-icons-round text-lg">{currentStep === STEPS.length - 1 ? 'rocket_launch' : 'arrow_forward'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
src/components/Settings.tsx
Normal file
424
src/components/Settings.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Cpu, X, Database, Globe, ShieldCheck, ChevronDown, RefreshCw, Zap, Download, Eye } from 'lucide-react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { webLlmService } from '../lib/webLlmService';
|
||||||
|
import type { WebLlmProgress } from '../lib/webLlmService';
|
||||||
|
import { visionService } from '../lib/visionService';
|
||||||
|
import type { VisionProgress } from '../lib/visionService';
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaModel {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modified_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||||
|
const {
|
||||||
|
ollamaUrl, setOllamaUrl,
|
||||||
|
modelName, setModelName,
|
||||||
|
apiKey, setApiKey,
|
||||||
|
aiMode, setAiMode,
|
||||||
|
onlineProvider, setOnlineProvider
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline');
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<OllamaModel[]>([]);
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||||
|
|
||||||
|
// Browser Model State
|
||||||
|
const [browserProgress, setBrowserProgress] = useState<WebLlmProgress | null>(null);
|
||||||
|
const [isBrowserLoading, setIsBrowserLoading] = useState(false);
|
||||||
|
const [isBrowserReady, setIsBrowserReady] = useState(false);
|
||||||
|
|
||||||
|
// Vision Model State
|
||||||
|
const [visionProgress, setVisionProgress] = useState<VisionProgress | null>(null);
|
||||||
|
const [isVisionLoading, setIsVisionLoading] = useState(false);
|
||||||
|
const [isVisionReady, setIsVisionReady] = useState(false);
|
||||||
|
|
||||||
|
const checkBrowserStatus = useCallback(() => {
|
||||||
|
const llmStatus = webLlmService.getStatus();
|
||||||
|
setIsBrowserReady(llmStatus.isReady);
|
||||||
|
setIsBrowserLoading(llmStatus.isLoading);
|
||||||
|
|
||||||
|
const visionStatus = visionService.getStatus();
|
||||||
|
setIsVisionReady(visionStatus.isReady);
|
||||||
|
setIsVisionLoading(visionStatus.isLoading);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initBrowserModel = async () => {
|
||||||
|
setIsBrowserLoading(true);
|
||||||
|
try {
|
||||||
|
await webLlmService.initialize((progress) => {
|
||||||
|
setBrowserProgress(progress);
|
||||||
|
});
|
||||||
|
setIsBrowserReady(true);
|
||||||
|
setSystemStatus('online'); // Logic is ready
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setSystemStatus('offline');
|
||||||
|
} finally {
|
||||||
|
setIsBrowserLoading(false);
|
||||||
|
setBrowserProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initVisionModel = async () => {
|
||||||
|
setIsVisionLoading(true);
|
||||||
|
try {
|
||||||
|
await visionService.initialize((progress) => {
|
||||||
|
setVisionProgress(progress);
|
||||||
|
});
|
||||||
|
setIsVisionReady(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsVisionLoading(false);
|
||||||
|
setVisionProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
if (!ollamaUrl) return;
|
||||||
|
setIsLoadingModels(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${ollamaUrl}/api/tags`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAvailableModels(data.models || []);
|
||||||
|
setSystemStatus('online');
|
||||||
|
} else {
|
||||||
|
setAvailableModels([]);
|
||||||
|
setSystemStatus('offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setAvailableModels([]);
|
||||||
|
setSystemStatus('offline');
|
||||||
|
}
|
||||||
|
setIsLoadingModels(false);
|
||||||
|
}, [ollamaUrl]);
|
||||||
|
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
setIsVerifying(true);
|
||||||
|
if (aiMode === 'offline') {
|
||||||
|
await fetchModels();
|
||||||
|
} else if (aiMode === 'browser') {
|
||||||
|
checkBrowserStatus();
|
||||||
|
setSystemStatus(webLlmService.getStatus().isReady ? 'online' : 'offline');
|
||||||
|
} else {
|
||||||
|
if (onlineProvider === 'ollama-cloud') {
|
||||||
|
await fetchModels();
|
||||||
|
} else {
|
||||||
|
const isKeyValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30);
|
||||||
|
setSystemStatus(isKeyValid ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsVerifying(false);
|
||||||
|
}, [aiMode, fetchModels, apiKey, onlineProvider, checkBrowserStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
checkStatus();
|
||||||
|
const interval = setInterval(checkStatus, 5000); // Check more frequently for browser status
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [isOpen, checkStatus]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
|
||||||
|
<div className="fixed top-24 right-12 w-96 floating-glass p-8 rounded-[2rem] z-[9999] animate-slide-up titanium-border shadow-2xl flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
<div>
|
||||||
|
<h3 className="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>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group">
|
||||||
|
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
||||||
|
<button
|
||||||
|
onClick={() => setAiMode('offline')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-[10px] font-bold transition-all uppercase tracking-wide
|
||||||
|
${aiMode === 'offline'
|
||||||
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
: 'text-tertiary hover:text-primary hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
Local
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiMode('browser')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-[10px] font-bold transition-all uppercase tracking-wide
|
||||||
|
${aiMode === 'browser'
|
||||||
|
? 'bg-violet-600 text-white shadow-lg shadow-violet-900/20'
|
||||||
|
: 'text-tertiary hover:text-primary hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
Browser
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiMode('online')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-[10px] font-bold transition-all uppercase tracking-wide
|
||||||
|
${aiMode === 'online'
|
||||||
|
? 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||||
|
: 'text-tertiary hover:text-primary hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Cloud
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiMode === 'browser' && (
|
||||||
|
<div className="space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
{/* Neural Engine Card */}
|
||||||
|
<div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 rounded-lg bg-violet-500/10">
|
||||||
|
<Zap className="w-4 h-4 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[11px] font-bold text-violet-200">Neural Engine (Text)</h4>
|
||||||
|
<p className="text-[9px] text-violet-400/60">Llama-3.2-1B-Instruct-q4f32_1</p>
|
||||||
|
</div>
|
||||||
|
{isBrowserReady && <div className="ml-auto w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBrowserLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-wider text-violet-300">
|
||||||
|
<span>{browserProgress?.text || 'Initializing...'}</span>
|
||||||
|
<span>{Math.round(browserProgress?.progress || 0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-black/40 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-violet-500 transition-all duration-300"
|
||||||
|
style={{ width: `${browserProgress?.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!isBrowserReady ? (
|
||||||
|
<button
|
||||||
|
onClick={initBrowserModel}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-[10px] font-bold uppercase tracking-wider transition-all shadow-lg shadow-violet-900/20"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Load Text Engine (~800MB)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-green-400 flex items-center gap-2 bg-green-500/10 p-2 rounded-lg border border-green-500/20">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
Text Engine Ready
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vision Engine Card */}
|
||||||
|
<div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 rounded-lg bg-violet-500/10">
|
||||||
|
<Eye className="w-4 h-4 text-violet-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[11px] font-bold text-violet-200">Vision Engine (Image)</h4>
|
||||||
|
<p className="text-[9px] text-violet-400/60">Florence-2-base (~200MB)</p>
|
||||||
|
</div>
|
||||||
|
{isVisionReady && <div className="ml-auto w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isVisionLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-[9px] font-bold uppercase tracking-wider text-violet-300">
|
||||||
|
<span>{visionProgress?.status || 'Loading...'}</span>
|
||||||
|
{visionProgress?.progress !== undefined && <span>{Math.round(visionProgress.progress)}%</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-black/40 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-violet-500 transition-all duration-300"
|
||||||
|
style={{ width: `${visionProgress?.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!isVisionReady ? (
|
||||||
|
<button
|
||||||
|
onClick={initVisionModel}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-violet-600 hover:bg-violet-500 text-white text-[10px] font-bold uppercase tracking-wider transition-all shadow-lg shadow-violet-900/20"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Load Vision Engine (~200MB)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-green-400 flex items-center gap-2 bg-green-500/10 p-2 rounded-lg border border-green-500/20">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
Vision Engine Ready
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[9px] text-slate-500 leading-relaxed text-center px-2">
|
||||||
|
Runs entirely in your browser using WebGPU/WASM. No internet required after initial download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiMode === 'offline' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3 animate-fade-in">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">Ollama Connection</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ollamaUrl}
|
||||||
|
onChange={(e) => setOllamaUrl(e.target.value)}
|
||||||
|
className="flex-1 bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600"
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={checkStatus}
|
||||||
|
disabled={isVerifying}
|
||||||
|
className="px-4 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all flex items-center justify-center"
|
||||||
|
title="Test Connection"
|
||||||
|
>
|
||||||
|
<Database className={`w-4 h-4 text-tertiary ${isVerifying ? 'animate-pulse text-blue-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">Local Model</label>
|
||||||
|
<button
|
||||||
|
onClick={fetchModels}
|
||||||
|
disabled={isLoadingModels}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-white/5 transition-colors"
|
||||||
|
title="Refresh models"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 text-tertiary ${isLoadingModels ? 'animate-spin text-blue-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableModels.length > 0 ? (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={modelName}
|
||||||
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
|
className="w-full appearance-none bg-black/20 border border-white/10 rounded-xl p-3 pr-10 text-[12px] font-bold outline-none focus:border-blue-500/50 transition-all text-primary cursor-pointer"
|
||||||
|
>
|
||||||
|
{!availableModels.find(m => m.name === modelName) && modelName && (
|
||||||
|
<option value={modelName}>{modelName}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((model) => (
|
||||||
|
<option key={model.name} value={model.name}>
|
||||||
|
{model.name} ({(model.size / 1024 / 1024 / 1024).toFixed(1)}GB)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modelName}
|
||||||
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] font-bold outline-none focus:border-blue-500/50 transition-all text-primary placeholder:text-slate-600"
|
||||||
|
placeholder="llava"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{aiMode === 'online' && (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">AI Engine</label>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{[
|
||||||
|
{ id: 'openai', label: 'OpenAI (GPT-4o)', color: 'bg-green-500' },
|
||||||
|
{ id: 'gemini', label: 'Google Gemini Pro', color: 'bg-blue-500' },
|
||||||
|
{ id: 'ollama-cloud', label: 'Remote Ollama', color: 'bg-orange-500' }
|
||||||
|
].map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setOnlineProvider(p.id as any)}
|
||||||
|
className={`relative overflow-hidden w-full px-4 py-3 rounded-xl border transition-all text-left group
|
||||||
|
${onlineProvider === p.id
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10'
|
||||||
|
: 'border-white/5 bg-white/5 hover:bg-white/10 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between relative z-10">
|
||||||
|
<span className={`text-[11px] font-bold ${onlineProvider === p.id ? 'text-blue-400' : 'text-slate-400 group-hover:text-slate-200'}`}>
|
||||||
|
{p.label}
|
||||||
|
</span>
|
||||||
|
{onlineProvider === p.id && <div className="w-1.5 h-1.5 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)]" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">
|
||||||
|
{onlineProvider === 'ollama-cloud' ? 'API Endpoint' : 'Secret Key'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={onlineProvider === 'ollama-cloud' ? 'text' : 'password'}
|
||||||
|
value={onlineProvider === 'ollama-cloud' ? ollamaUrl : apiKey}
|
||||||
|
onChange={(e) => onlineProvider === 'ollama-cloud' ? setOllamaUrl(e.target.value) : setApiKey(e.target.value)}
|
||||||
|
className="w-full bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600"
|
||||||
|
placeholder={onlineProvider === 'ollama-cloud' ? 'https://api.example.com' : 'sk-...'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Status - Clean No Border */}
|
||||||
|
<div className="flex items-center gap-3 pl-1 pt-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full transition-all duration-500
|
||||||
|
${systemStatus === 'online'
|
||||||
|
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.5)]'
|
||||||
|
: (aiMode === 'browser' && isBrowserLoading ? 'bg-violet-500 animate-pulse' : 'bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]')
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider
|
||||||
|
${systemStatus === 'online' ? 'text-emerald-500' : (aiMode === 'browser' && isBrowserLoading ? 'text-violet-500' : 'text-red-500')}`}
|
||||||
|
>
|
||||||
|
{isVerifying || (aiMode === 'browser' && isBrowserLoading) ? 'Initializing Neural Engine...' : (systemStatus === 'online' ? 'Systems Nominal' : 'No Connection')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{(isVerifying || (aiMode === 'browser' && isBrowserLoading)) && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<div className="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/SmartGuide.tsx
Normal file
89
src/components/SmartGuide.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
|
||||||
|
import React, { 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.
|
||||||
196
src/components/VisualOrganizerPanel.tsx
Normal file
196
src/components/VisualOrganizerPanel.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
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 type { LayoutSuggestion } from '../types/visualOrganization';
|
||||||
|
|
||||||
|
export const VisualOrganizerPanel: React.FC = () => {
|
||||||
|
const { analyzeLayout, generateSuggestions, applySuggestion, getPresets } = useVisualOrganizer();
|
||||||
|
const { nodes, edges, setNodes, setEdges } = useDiagramStore(); // Needed for snapshotting
|
||||||
|
const [suggestions, setSuggestions] = useState<LayoutSuggestion[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [analysis, setAnalysis] = useState<any>(null);
|
||||||
|
const [snapshotHistory, setSnapshotHistory] = useState<Array<{ id: string, timestamp: number, nodes: any[], edges: any[], name: string }>>([]);
|
||||||
|
const [previewState, setPreviewState] = useState<{
|
||||||
|
originalNodes: any[];
|
||||||
|
originalEdges: any[];
|
||||||
|
suggestionId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const takeSnapshot = (name: string) => {
|
||||||
|
setSnapshotHistory(prev => [
|
||||||
|
{ id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), nodes: [...nodes], edges: [...edges], name },
|
||||||
|
...prev.slice(0, 4) // Keep last 5
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreSnapshot = (snapshot: any) => {
|
||||||
|
setNodes(snapshot.nodes);
|
||||||
|
setEdges(snapshot.edges);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
const result = analyzeLayout();
|
||||||
|
setAnalysis(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateSuggestions = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await generateSuggestions();
|
||||||
|
setSuggestions(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate suggestions:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewSuggestion = (suggestion: LayoutSuggestion) => {
|
||||||
|
// Save current state
|
||||||
|
if (!previewState) {
|
||||||
|
setPreviewState({
|
||||||
|
originalNodes: [...nodes],
|
||||||
|
originalEdges: [...edges],
|
||||||
|
suggestionId: suggestion.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Apply suggestion
|
||||||
|
applySuggestion(suggestion);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPreview = (suggestion: LayoutSuggestion) => {
|
||||||
|
takeSnapshot(`Before ${suggestion.title}`);
|
||||||
|
setPreviewState(null);
|
||||||
|
setSuggestions(suggestions.filter(s => s.id !== suggestion.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPreview = () => {
|
||||||
|
if (previewState) {
|
||||||
|
setNodes(previewState.originalNodes);
|
||||||
|
setEdges(previewState.originalEdges);
|
||||||
|
setPreviewState(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="visual-organizer-panel">
|
||||||
|
<Card className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Visual Organizer</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleAnalyze} variant="secondary">
|
||||||
|
Analyze Layout
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleGenerateSuggestions} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Generating...' : 'Get Suggestions'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<h4 className="font-medium mb-2">Layout Analysis</h4>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p>Nodes: {analysis.metrics.nodeCount}</p>
|
||||||
|
<p>Edges: {analysis.metrics.edgeCount}</p>
|
||||||
|
<p>Issues: {analysis.issues.length}</p>
|
||||||
|
<p>Strengths: {analysis.strengths.length}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="mb-4">
|
||||||
|
<h4 className="font-medium mb-2">Quick Layout Presets</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{getPresets().map(preset => (
|
||||||
|
<Button
|
||||||
|
key={preset.id}
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreviewSuggestion(preset)}
|
||||||
|
className="h-auto flex flex-col items-start p-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="font-bold text-xs mb-1">{preset.title}</span>
|
||||||
|
<span className="text-[10px] text-gray-500 font-normal leading-tight">{preset.description}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<h4 className="font-medium mb-2">AI Suggestions</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map((suggestion) => {
|
||||||
|
const isPreviewing = previewState?.suggestionId === suggestion.id;
|
||||||
|
const isOtherPreviewing = previewState !== null && !isPreviewing;
|
||||||
|
|
||||||
|
if (isOtherPreviewing) return null; // Hide other suggestions while previewing
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={suggestion.id} className={`border rounded p-2 ${isPreviewing ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' : ''}`}>
|
||||||
|
<h5 className="font-medium">{suggestion.title}</h5>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{suggestion.description}</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
{!isPreviewing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreviewSuggestion(suggestion)}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setSuggestions(suggestions.filter(s => s.id !== suggestion.id))}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleConfirmPreview(suggestion)}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleCancelPreview}
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshotHistory.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<h4 className="font-medium mb-2">History & Comparison</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{snapshotHistory.map(snap => (
|
||||||
|
<div key={snap.id} className="flex items-center justify-between text-xs p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded">
|
||||||
|
<span>{snap.name}</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => restoreSnapshot(snap)} className="h-6 px-2">
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
158
src/components/edges/AnimatedEdge.tsx
Normal file
158
src/components/edges/AnimatedEdge.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { BaseEdge, getSmoothStepPath, getBezierPath, Position, EdgeLabelRenderer } from '@xyflow/react';
|
||||||
|
|
||||||
|
interface AnimatedEdgeProps {
|
||||||
|
id: string;
|
||||||
|
sourceX: number;
|
||||||
|
sourceY: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
sourcePosition: Position;
|
||||||
|
targetPosition: Position;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
markerEnd?: string;
|
||||||
|
label?: any;
|
||||||
|
data?: { curved?: boolean; offset?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
style = {},
|
||||||
|
markerEnd,
|
||||||
|
label,
|
||||||
|
data,
|
||||||
|
}: AnimatedEdgeProps) {
|
||||||
|
const isCurved = data?.curved !== false;
|
||||||
|
const offset = data?.offset || 0;
|
||||||
|
|
||||||
|
// Apply offset for parallel edges
|
||||||
|
const offsetX = sourcePosition === Position.Left || sourcePosition === Position.Right ? 0 : offset;
|
||||||
|
const offsetY = sourcePosition === Position.Top || sourcePosition === Position.Bottom ? 0 : offset;
|
||||||
|
|
||||||
|
// Use SmoothStep for cleaner orthogonal routing, Bezier for curved
|
||||||
|
const [edgePath, labelX, labelY] = isCurved
|
||||||
|
? getBezierPath({
|
||||||
|
sourceX: sourceX + offsetX,
|
||||||
|
sourceY: sourceY + offsetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX: targetX + offsetX,
|
||||||
|
targetY: targetY + offsetY,
|
||||||
|
targetPosition,
|
||||||
|
curvature: 0.25, // Gentle curve
|
||||||
|
})
|
||||||
|
: getSmoothStepPath({
|
||||||
|
sourceX: sourceX + offsetX,
|
||||||
|
sourceY: sourceY + offsetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX: targetX + offsetX,
|
||||||
|
targetY: targetY + offsetY,
|
||||||
|
targetPosition,
|
||||||
|
borderRadius: 12, // Rounded corners
|
||||||
|
offset: 20, // Offset from node for cleaner routing
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDashed = style?.strokeDasharray;
|
||||||
|
const strokeColor = isDashed ? '#94a3b8' : '#6366f1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Shadow/glow effect for depth */}
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={6}
|
||||||
|
strokeOpacity={0.15}
|
||||||
|
style={{ filter: 'blur(3px)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main edge */}
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
markerEnd={markerEnd || 'url(#arrow)'}
|
||||||
|
style={{
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: strokeColor,
|
||||||
|
strokeLinecap: 'round',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated dot traveling along the edge */}
|
||||||
|
<circle r="3" fill={strokeColor}>
|
||||||
|
<animateMotion dur={isDashed ? '4s' : '2s'} repeatCount="indefinite" path={edgePath} />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
{/* Edge label - centered on the line */}
|
||||||
|
{label && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 bg-slate-900 border border-slate-600/50 rounded-md text-[10px] font-semibold text-slate-200 shadow-lg whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StraightEdge(props: AnimatedEdgeProps) {
|
||||||
|
return <AnimatedEdge {...props} data={{ ...props.data, curved: false }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurvedEdge(props: AnimatedEdgeProps) {
|
||||||
|
return <AnimatedEdge {...props} data={{ ...props.data, curved: true }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EdgeDefs() {
|
||||||
|
return (
|
||||||
|
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||||
|
<defs>
|
||||||
|
{/* Arrow marker with better styling */}
|
||||||
|
<marker
|
||||||
|
id="arrow"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="9"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="5"
|
||||||
|
markerHeight="5"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6366f1" />
|
||||||
|
</marker>
|
||||||
|
{/* Dotted arrow marker */}
|
||||||
|
<marker
|
||||||
|
id="arrow-dotted"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="9"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="5"
|
||||||
|
markerHeight="5"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#94a3b8" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const edgeTypes = {
|
||||||
|
animated: AnimatedEdge,
|
||||||
|
curved: CurvedEdge,
|
||||||
|
straight: StraightEdge,
|
||||||
|
};
|
||||||
183
src/components/editor/EditorHeader.tsx
Normal file
183
src/components/editor/EditorHeader.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
|
||||||
|
ChevronDown, FileCode, ImageIcon, FileText, Frame
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFlowStore } from '../../store';
|
||||||
|
import {
|
||||||
|
exportToPng, exportToJpg, exportToSvg,
|
||||||
|
exportToTxt, downloadMermaid
|
||||||
|
} from '../../lib/exportUtils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SettingsModal } from '../Settings';
|
||||||
|
|
||||||
|
export function EditorHeader() {
|
||||||
|
const {
|
||||||
|
nodes, edges, leftPanelOpen, setLeftPanelOpen,
|
||||||
|
rightPanelOpen, setRightPanelOpen,
|
||||||
|
theme, toggleTheme,
|
||||||
|
focusMode, setFocusMode,
|
||||||
|
saveDiagram
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
||||||
|
if (name) {
|
||||||
|
saveDiagram(name);
|
||||||
|
}
|
||||||
|
setTimeout(() => setIsSaving(false), 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code') => {
|
||||||
|
setShowExportMenu(false);
|
||||||
|
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (format) {
|
||||||
|
case 'png':
|
||||||
|
if (viewport) await exportToPng(viewport);
|
||||||
|
break;
|
||||||
|
case 'jpg':
|
||||||
|
if (viewport) await exportToJpg(viewport);
|
||||||
|
break;
|
||||||
|
case 'svg':
|
||||||
|
exportToSvg(nodes, edges);
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
exportToTxt(nodes, edges);
|
||||||
|
break;
|
||||||
|
case 'code':
|
||||||
|
downloadMermaid(nodes, edges);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Export failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Link to="/" className="flex items-center gap-2 group">
|
||||||
|
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-all duration-500">
|
||||||
|
<Zap className="w-3.5 h-3.5 text-white fill-white/20" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-black tracking-tight text-slate-800 dark:text-primary">SystemArchitect</span>
|
||||||
|
</Link>
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusMode(!focusMode)}
|
||||||
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${focusMode ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`}
|
||||||
|
title={focusMode ? "Exit Focus Mode" : "Enter Focus Mode"}
|
||||||
|
>
|
||||||
|
{focusMode ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${showSettings ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`}
|
||||||
|
title="System Settings"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-black/5 dark:hover:bg-void transition-all text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary"
|
||||||
|
>
|
||||||
|
{theme === 'light' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-4 w-px bg-slate-200 dark:bg-slate-500/10 mx-2"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={nodes.length === 0 || isSaving}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-white dark:bg-surface border border-slate-200 dark:border-white/10 text-slate-600 dark:text-secondary hover:text-blue-600 dark:hover:text-blue-500 hover:border-blue-300 dark:hover:border-blue-500/30 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save className={`w-3 h-3 ${isSaving ? 'animate-bounce' : ''}`} />
|
||||||
|
<span>{isSaving ? 'Saving...' : 'Save Draft'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Export Dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||||
|
disabled={nodes.length === 0}
|
||||||
|
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed ${showExportMenu ? 'bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span>Export</span>
|
||||||
|
<ChevronDown className={`w-3 h-3 transition-transform ${showExportMenu ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExportMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-[70]" onClick={() => setShowExportMenu(false)} />
|
||||||
|
<div className="absolute top-full mt-2 right-0 w-48 floating-glass border titanium-border rounded-xl overflow-hidden z-[80] shadow-2xl animate-slide-up p-1">
|
||||||
|
<button onClick={() => handleExport('code')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
|
||||||
|
<FileCode className="w-3.5 h-3.5 text-blue-500" />
|
||||||
|
Mermaid Code
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('png')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5 text-indigo-500" />
|
||||||
|
PNG Image
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('jpg')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
||||||
|
<ImageIcon className="w-3.5 h-3.5 text-amber-500" />
|
||||||
|
JPG Image
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('svg')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
||||||
|
<Frame className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
SVG Vector
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleExport('txt')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
Logic Summary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/editor/Sidebar.tsx
Normal file
51
src/components/editor/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
side: 'left' | 'right';
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ side, isOpen, onToggle, children, footer }: SidebarProps) {
|
||||||
|
const isLeft = side === 'left';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full shrink-0">
|
||||||
|
<aside
|
||||||
|
className={`transition-all duration-500 ease-in-out flex flex-col overflow-hidden border-white/5 bg-slate-950/50 backdrop-blur-xl ${isOpen ? 'w-80' : 'w-0'
|
||||||
|
} ${isLeft ? 'border-r' : 'border-l'}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
{isOpen && children}
|
||||||
|
</div>
|
||||||
|
{isOpen && footer && (
|
||||||
|
<div className="p-3 border-t border-white/5 text-center text-[10px] font-bold text-slate-500 uppercase tracking-widest bg-slate-900/30">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 z-40 w-6 h-12 flex items-center justify-center glass-panel shadow-2xl transition-all duration-300 hover:scale-110 active:scale-95 ${isLeft
|
||||||
|
? 'rounded-r-xl border-l-0 left-full -translate-x-full group-hover:left-full'
|
||||||
|
: 'rounded-l-xl border-r-0 right-full translate-x-full group-hover:right-full'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
[isLeft ? 'left' : 'right']: isOpen ? '100%' : '0',
|
||||||
|
transform: `translateY(-50%) ${!isOpen && !isLeft ? 'translateX(0)' : ''}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLeft ? (
|
||||||
|
isOpen ? <ChevronLeft className="w-4 h-4 text-slate-400" /> : <ChevronRight className="w-4 h-4 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
isOpen ? <ChevronRight className="w-4 h-4 text-slate-400" /> : <ChevronLeft className="w-4 h-4 text-slate-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
434
src/components/nodes/CustomNodes.tsx
Normal file
434
src/components/nodes/CustomNodes.tsx
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position, NodeResizer } from '@xyflow/react';
|
||||||
|
import { Database, Cpu, Users, Globe, Server, Zap, Play, Square, GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-contrast, accessible node color palette
|
||||||
|
* Each color has a background, border, and text color for maximum readability
|
||||||
|
*/
|
||||||
|
const NODE_STYLES = {
|
||||||
|
ai: {
|
||||||
|
bg: 'bg-violet-500/15 dark:bg-violet-500/20',
|
||||||
|
solid: 'bg-violet-200 dark:bg-violet-900',
|
||||||
|
border: 'border-violet-500',
|
||||||
|
text: 'text-violet-700 dark:text-violet-200',
|
||||||
|
textSolid: 'text-violet-700 dark:text-violet-300',
|
||||||
|
icon: Cpu,
|
||||||
|
glow: 'shadow-violet-500/20',
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
bg: 'bg-amber-500/15 dark:bg-amber-500/20',
|
||||||
|
solid: 'bg-amber-200 dark:bg-amber-900',
|
||||||
|
border: 'border-amber-500',
|
||||||
|
text: 'text-amber-800 dark:text-amber-200',
|
||||||
|
textSolid: 'text-amber-800 dark:text-amber-300',
|
||||||
|
icon: Users,
|
||||||
|
glow: 'shadow-amber-500/20',
|
||||||
|
},
|
||||||
|
platform: {
|
||||||
|
bg: 'bg-pink-500/15 dark:bg-pink-500/20',
|
||||||
|
solid: 'bg-pink-200 dark:bg-pink-900',
|
||||||
|
border: 'border-pink-500',
|
||||||
|
text: 'text-pink-700 dark:text-pink-300',
|
||||||
|
textSolid: 'text-pink-700 dark:text-pink-300',
|
||||||
|
icon: Globe,
|
||||||
|
glow: 'shadow-pink-500/20',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
bg: 'bg-cyan-500/15 dark:bg-cyan-500/20',
|
||||||
|
solid: 'bg-cyan-200 dark:bg-cyan-900',
|
||||||
|
border: 'border-cyan-500',
|
||||||
|
text: 'text-cyan-700 dark:text-cyan-300',
|
||||||
|
textSolid: 'text-cyan-800 dark:text-cyan-300',
|
||||||
|
icon: Database,
|
||||||
|
glow: 'shadow-cyan-500/20',
|
||||||
|
},
|
||||||
|
tech: {
|
||||||
|
bg: 'bg-slate-500/15 dark:bg-slate-500/20',
|
||||||
|
solid: 'bg-slate-200 dark:bg-slate-700',
|
||||||
|
border: 'border-slate-500',
|
||||||
|
text: 'text-slate-700 dark:text-slate-300',
|
||||||
|
textSolid: 'text-slate-700 dark:text-slate-200',
|
||||||
|
icon: Server,
|
||||||
|
glow: 'shadow-slate-500/20',
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
bg: 'bg-emerald-500/15 dark:bg-emerald-500/20',
|
||||||
|
solid: 'bg-emerald-200 dark:bg-emerald-900',
|
||||||
|
border: 'border-emerald-500',
|
||||||
|
text: 'text-emerald-700 dark:text-emerald-300',
|
||||||
|
textSolid: 'text-emerald-700 dark:text-emerald-300',
|
||||||
|
icon: Play,
|
||||||
|
glow: 'shadow-emerald-500/20',
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
bg: 'bg-rose-500/15 dark:bg-rose-500/20',
|
||||||
|
solid: 'bg-rose-200 dark:bg-rose-900',
|
||||||
|
border: 'border-rose-500',
|
||||||
|
text: 'text-rose-700 dark:text-rose-300',
|
||||||
|
textSolid: 'text-rose-700 dark:text-rose-300',
|
||||||
|
icon: Square,
|
||||||
|
glow: 'shadow-rose-500/20',
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
bg: 'bg-purple-500/15 dark:bg-purple-500/20',
|
||||||
|
solid: 'bg-purple-200 dark:bg-purple-900',
|
||||||
|
border: 'border-purple-500',
|
||||||
|
text: 'text-purple-700 dark:text-purple-300',
|
||||||
|
textSolid: 'text-purple-700 dark:text-purple-300',
|
||||||
|
icon: GitBranch,
|
||||||
|
glow: 'shadow-purple-500/20',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
bg: 'bg-blue-500/15 dark:bg-blue-500/20',
|
||||||
|
solid: 'bg-blue-200 dark:bg-blue-900',
|
||||||
|
border: 'border-blue-500',
|
||||||
|
text: 'text-blue-700 dark:text-blue-300',
|
||||||
|
textSolid: 'text-blue-700 dark:text-blue-300',
|
||||||
|
icon: Zap,
|
||||||
|
glow: 'shadow-blue-500/20',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeStyleKey = keyof typeof NODE_STYLES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine node style based on label content
|
||||||
|
*/
|
||||||
|
function getNodeStyle(label: string = '', type?: string): NodeStyleKey {
|
||||||
|
const l = label.toLowerCase();
|
||||||
|
|
||||||
|
// Type-based matching first
|
||||||
|
if (type === 'start' || type === 'startNode') return 'start';
|
||||||
|
if (type === 'end' || type === 'endNode') return 'end';
|
||||||
|
if (type === 'decision' || type === 'decisionNode') return 'decision';
|
||||||
|
if (type === 'database' || type === 'databaseNode') return 'data';
|
||||||
|
|
||||||
|
// Content-based matching - Decision keywords take priority
|
||||||
|
if (l.includes('approve') || l.includes('decision') || l.includes('verify') || l.includes('check') || l.includes('validate') || l.includes('confirm') || l.includes('?')) return 'decision';
|
||||||
|
if (l.includes('ai') || l.includes('director') || l.includes('generate') || l.includes('neural')) return 'ai';
|
||||||
|
if (l.includes('intern') || l.includes('team') || l.includes('edit') || l.includes('review') || l.includes('publish') || l.includes('fine') || l.includes('human')) return 'team';
|
||||||
|
if (l.includes('platform') || l.includes('tiktok') || l.includes('shop') || l.includes('youtube') || l.includes('instagram')) return 'platform';
|
||||||
|
if (l.includes('data') || l.includes('analyst') || l.includes('feedback') || l.includes('collect') || l.includes('ctr') || l.includes('cvr')) return 'data';
|
||||||
|
if (l.includes('tech') || l.includes('system') || l.includes('server') || l.includes('api')) return 'tech';
|
||||||
|
if (l.includes('start') || l.includes('begin') || l.includes('init')) return 'start';
|
||||||
|
if (l.includes('end') || l.includes('finish') || l.includes('complete')) return 'end';
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable Handle Component with improved styling
|
||||||
|
interface HandleProps {
|
||||||
|
type: 'source' | 'target';
|
||||||
|
position: Position;
|
||||||
|
id?: string;
|
||||||
|
styleKey: NodeStyleKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomHandle = memo(({ type, position, id, styleKey }: HandleProps) => {
|
||||||
|
const style = NODE_STYLES[styleKey];
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
type={type}
|
||||||
|
position={position}
|
||||||
|
id={id}
|
||||||
|
className={`!w-2.5 !h-2.5 !border-2 !border-current ${style.text} !bg-white dark:!bg-slate-900 !opacity-0 hover:!opacity-100 transition-all duration-200`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NodeComponentProps {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
label?: string;
|
||||||
|
metadata?: {
|
||||||
|
techStack?: string[];
|
||||||
|
role?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
selected: boolean;
|
||||||
|
type?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Standard Node - Rounded corners for "human" tasks
|
||||||
|
// ============================================
|
||||||
|
const StandardNode = memo(({ data, selected, type, style: propStyle }: NodeComponentProps) => {
|
||||||
|
const label = data.label || 'Node';
|
||||||
|
const styleKey = getNodeStyle(label, type);
|
||||||
|
const themeStyle = NODE_STYLES[styleKey];
|
||||||
|
const Icon = themeStyle.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={propStyle}
|
||||||
|
className={`
|
||||||
|
group relative px-5 py-3.5 rounded-2xl border-2 transition-all duration-300
|
||||||
|
min-w-[160px] max-w-[240px]
|
||||||
|
${themeStyle.bg} ${themeStyle.border}
|
||||||
|
${selected ? `ring-4 ring-current/20 shadow-xl ${themeStyle.glow}` : 'border-opacity-50 hover:border-opacity-100'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Icon Badge */}
|
||||||
|
<div className={`absolute -top-3 -left-3 w-8 h-8 rounded-xl ${themeStyle.solid} ${themeStyle.border} border-2 flex items-center justify-center shadow-sm z-10 block`}>
|
||||||
|
<Icon className={`w-4 h-4 ${themeStyle.textSolid}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label with high contrast */}
|
||||||
|
<span className={`text-[13px] font-bold block text-center leading-relaxed ${themeStyle.text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Metadata preview on hover */}
|
||||||
|
{data.metadata?.role && (
|
||||||
|
<div className="mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span className="text-[9px] text-slate-500 dark:text-slate-400 uppercase tracking-wider block text-center">
|
||||||
|
{data.metadata.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CustomHandle type="target" position={Position.Top} styleKey={styleKey} />
|
||||||
|
<CustomHandle type="source" position={Position.Bottom} styleKey={styleKey} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Terminal Node - Pill shape for Start/End
|
||||||
|
// ============================================
|
||||||
|
const TerminalNode = memo(({ data, selected, type, style: propStyle }: NodeComponentProps) => {
|
||||||
|
const label = data.label || 'Start';
|
||||||
|
const isEnd = type === 'end' || type === 'endNode' || label.toLowerCase().includes('end');
|
||||||
|
const styleKey = isEnd ? 'end' : 'start';
|
||||||
|
const themeStyle = NODE_STYLES[styleKey];
|
||||||
|
const Icon = themeStyle.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={propStyle}
|
||||||
|
className={`
|
||||||
|
group relative px-8 py-3 rounded-full border-2 transition-all duration-300
|
||||||
|
min-w-[120px]
|
||||||
|
${themeStyle.bg} ${themeStyle.border}
|
||||||
|
${selected ? `ring-4 ring-current/20 shadow-xl ${themeStyle.glow}` : 'border-opacity-50 hover:border-opacity-100'}
|
||||||
|
`}>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${themeStyle.text}`} />
|
||||||
|
<span className={`text-[11px] font-black uppercase tracking-widest ${themeStyle.text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomHandle type="target" position={Position.Top} styleKey={styleKey} />
|
||||||
|
<CustomHandle type="source" position={Position.Bottom} styleKey={styleKey} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Decision Node - Diamond shape for conditionals
|
||||||
|
// ============================================
|
||||||
|
const DecisionNodeComponent = memo(({ data, selected, style: propStyle }: NodeComponentProps) => {
|
||||||
|
const label = data.label || 'Decision';
|
||||||
|
const style = NODE_STYLES.decision;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={propStyle} className="relative w-[130px] h-[100px] flex items-center justify-center group">
|
||||||
|
{/* Diamond background */}
|
||||||
|
<div className={`
|
||||||
|
absolute inset-2 border-2 rotate-45 rounded-xl transition-all duration-300
|
||||||
|
${style.bg} ${style.border}
|
||||||
|
${selected ? `shadow-xl ${style.glow}` : 'border-opacity-50'}
|
||||||
|
`} />
|
||||||
|
|
||||||
|
{/* Label (not rotated) */}
|
||||||
|
<div className="relative z-10 text-center px-2">
|
||||||
|
<GitBranch className={`w-4 h-4 mx-auto mb-1 ${style.text}`} />
|
||||||
|
<span className={`text-[11px] font-bold leading-tight ${style.text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomHandle type="target" position={Position.Top} styleKey="decision" />
|
||||||
|
<CustomHandle type="source" position={Position.Bottom} styleKey="decision" />
|
||||||
|
<CustomHandle type="source" position={Position.Right} id="yes" styleKey="start" />
|
||||||
|
<CustomHandle type="source" position={Position.Left} id="no" styleKey="end" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Database Node - Cylinder shape for data stores
|
||||||
|
// ============================================
|
||||||
|
const DatabaseNodeComponent = memo(({ data, selected, style: propStyle }: NodeComponentProps) => {
|
||||||
|
const label = data.label || 'Database';
|
||||||
|
const style = NODE_STYLES.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={propStyle}
|
||||||
|
className={`
|
||||||
|
group relative px-6 py-5 rounded-xl border-2 transition-all duration-300
|
||||||
|
min-w-[150px]
|
||||||
|
${style.bg} ${style.border}
|
||||||
|
${selected ? `ring-4 ring-current/20 shadow-xl ${style.glow}` : 'border-opacity-50 hover:border-opacity-100'}
|
||||||
|
`}>
|
||||||
|
{/* Cylinder top cap */}
|
||||||
|
<div className={`absolute top-0 left-4 right-4 h-2 ${style.border} border-2 rounded-t-full bg-current opacity-20`} />
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center pt-2">
|
||||||
|
<Database className={`w-5 h-5 mb-2 ${style.text}`} />
|
||||||
|
<span className={`text-[12px] font-bold text-center ${style.text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cylinder bottom cap */}
|
||||||
|
<div className={`absolute bottom-0 left-4 right-4 h-2 ${style.border} border-2 rounded-b-full bg-current opacity-20`} />
|
||||||
|
|
||||||
|
<CustomHandle type="target" position={Position.Top} styleKey="data" />
|
||||||
|
<CustomHandle type="source" position={Position.Bottom} styleKey="data" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Group/Swimlane Node - Resizable container with glow
|
||||||
|
// ============================================
|
||||||
|
export const GroupNode = memo(({ data, selected }: { data: { label?: string; color?: string }; selected?: boolean }) => {
|
||||||
|
const label = data.label || 'Group';
|
||||||
|
const borderColor = data.color || '#f59e0b';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Resize handles - visible when selected */}
|
||||||
|
<NodeResizer
|
||||||
|
color={borderColor}
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={200}
|
||||||
|
minHeight={150}
|
||||||
|
handleStyle={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: borderColor,
|
||||||
|
border: '2px solid white',
|
||||||
|
}}
|
||||||
|
lineStyle={{
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: borderColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative rounded-2xl w-full h-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(180deg, ${borderColor}25 0%, ${borderColor}10 100%)`,
|
||||||
|
border: `2px solid ${borderColor}${selected ? '' : '80'}`,
|
||||||
|
boxShadow: selected
|
||||||
|
? `0 0 40px ${borderColor}40, 0 4px 20px rgba(0,0,0,0.4)`
|
||||||
|
: `0 0 30px ${borderColor}25, 0 4px 20px rgba(0,0,0,0.3)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top gradient bar */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 right-0 h-1 rounded-t-2xl"
|
||||||
|
style={{ background: `linear-gradient(90deg, ${borderColor}, ${borderColor}80, ${borderColor})` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Corner decorations */}
|
||||||
|
<div
|
||||||
|
className="absolute top-3 left-3 w-4 h-4 border-l-2 border-t-2 rounded-tl-lg opacity-60"
|
||||||
|
style={{ borderColor: borderColor }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-3 right-3 w-4 h-4 border-r-2 border-t-2 rounded-tr-lg opacity-60"
|
||||||
|
style={{ borderColor: borderColor }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 left-3 w-4 h-4 border-l-2 border-b-2 rounded-bl-lg opacity-40"
|
||||||
|
style={{ borderColor: borderColor }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-3 right-3 w-4 h-4 border-r-2 border-b-2 rounded-br-lg opacity-40"
|
||||||
|
style={{ borderColor: borderColor }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label badge - top center */}
|
||||||
|
<div
|
||||||
|
className="absolute -top-4 left-1/2 transform -translate-x-1/2 px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-[0.15em] shadow-lg whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${borderColor}, ${borderColor}cc)`,
|
||||||
|
color: '#0f172a',
|
||||||
|
boxShadow: `0 4px 15px ${borderColor}50`,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// System/Server Node - Sharp edges for rigid systems
|
||||||
|
// ============================================
|
||||||
|
const SystemNode = memo(({ data, selected, type }: NodeComponentProps) => {
|
||||||
|
const label = data.label || 'System';
|
||||||
|
const styleKey = getNodeStyle(label, type);
|
||||||
|
const style = NODE_STYLES[styleKey];
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`
|
||||||
|
group relative px-5 py-4 rounded-lg border-2 transition-all duration-300
|
||||||
|
min-w-[160px] max-w-[240px]
|
||||||
|
${style.bg} ${style.border}
|
||||||
|
${selected ? `ring-4 ring-current/20 shadow-xl ${style.glow}` : 'border-opacity-50 hover:border-opacity-100'}
|
||||||
|
`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-8 h-8 rounded-lg ${style.bg} ${style.border} border flex items-center justify-center`}>
|
||||||
|
<Icon className={`w-4 h-4 ${style.text}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-[12px] font-bold ${style.text}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomHandle type="target" position={Position.Top} styleKey={styleKey} />
|
||||||
|
<CustomHandle type="source" position={Position.Bottom} styleKey={styleKey} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Exports Mapping
|
||||||
|
// ============================================
|
||||||
|
export const StartNode = memo((props: any) => <TerminalNode {...props} />);
|
||||||
|
export const EndNode = memo((props: any) => <TerminalNode {...props} />);
|
||||||
|
export const DecisionNode = memo((props: any) => <DecisionNodeComponent {...props} />);
|
||||||
|
export const DatabaseNode = memo((props: any) => <DatabaseNodeComponent {...props} />);
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
start: StartNode,
|
||||||
|
startNode: StartNode,
|
||||||
|
end: EndNode,
|
||||||
|
endNode: EndNode,
|
||||||
|
decision: DecisionNode,
|
||||||
|
decisionNode: DecisionNode,
|
||||||
|
database: DatabaseNode,
|
||||||
|
databaseNode: DatabaseNode,
|
||||||
|
process: StandardNode,
|
||||||
|
processNode: StandardNode,
|
||||||
|
client: StandardNode,
|
||||||
|
server: SystemNode,
|
||||||
|
system: SystemNode,
|
||||||
|
default: StandardNode,
|
||||||
|
group: GroupNode,
|
||||||
|
};
|
||||||
38
src/components/ui/Button.tsx
Normal file
38
src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/components/ui/Card.tsx
Normal file
28
src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +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>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
src/components/ui/ErrorBoundary.tsx
Normal file
104
src/components/ui/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* React Error Boundary for graceful error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
this.props.onError?.(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[400px] flex items-center justify-center p-8">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-6 flex items-center justify-center rounded-2xl bg-red-500/10">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-primary mb-2">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary mb-6">
|
||||||
|
An unexpected error occurred. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<details className="mb-6 text-left">
|
||||||
|
<summary className="cursor-pointer text-sm text-tertiary hover:text-secondary">
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-4 rounded-xl bg-slate-900/50 text-xs text-red-400 overflow-auto">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOC to wrap components with ErrorBoundary
|
||||||
|
*/
|
||||||
|
export function withErrorBoundary<P extends object>(
|
||||||
|
WrappedComponent: React.ComponentType<P>,
|
||||||
|
fallback?: ReactNode
|
||||||
|
) {
|
||||||
|
return function WithErrorBoundary(props: P) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={fallback}>
|
||||||
|
<WrappedComponent {...props} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
113
src/components/ui/ErrorMessage.tsx
Normal file
113
src/components/ui/ErrorMessage.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Error message components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface ErrorMessageProps {
|
||||||
|
message: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
className?: string;
|
||||||
|
variant?: 'inline' | 'toast' | 'banner';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
onDismiss,
|
||||||
|
className,
|
||||||
|
variant = 'inline',
|
||||||
|
}: ErrorMessageProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
inline: 'p-4 rounded-xl',
|
||||||
|
toast: 'p-4 rounded-xl shadow-2xl',
|
||||||
|
banner: 'p-3 rounded-none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-start gap-3 bg-red-500/10 border border-red-500/20 text-red-400',
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-500/20 transition-colors"
|
||||||
|
title="Retry"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-500/20 transition-colors"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state component
|
||||||
|
*/
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-col items-center justify-center text-center p-8',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="w-16 h-16 mb-4 flex items-center justify-center rounded-2xl bg-slate-500/10 text-slate-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold text-primary mb-1">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-secondary max-w-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<button
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="mt-4 btn-primary text-sm"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/ui/OrchestratorLoader.tsx
Normal file
23
src/components/ui/OrchestratorLoader.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/Spinner.tsx
Normal file
49
src/components/ui/Spinner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Spinner component for loading states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Spinner({ size = 'md', className, label }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex items-center justify-center gap-2', className)}>
|
||||||
|
<Loader2
|
||||||
|
className={clsx('animate-spin text-blue-500', sizeClasses[size])}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm text-secondary">{label}</span>
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{label || 'Loading...'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page loading overlay
|
||||||
|
*/
|
||||||
|
export function LoadingOverlay({ label }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-void/80 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl glass-panel">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
{label && (
|
||||||
|
<p className="text-sm font-medium text-secondary">{label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/ui/index.ts
Normal file
7
src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* UI components exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { Spinner, LoadingOverlay } from './Spinner';
|
||||||
|
export { ErrorMessage, EmptyState } from './ErrorMessage';
|
||||||
|
export { ErrorBoundary, withErrorBoundary } from './ErrorBoundary';
|
||||||
6
src/hooks/index.ts
Normal file
6
src/hooks/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* Custom hooks exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useAIGeneration } from './useAIGeneration';
|
||||||
|
export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts';
|
||||||
183
src/hooks/useAIGeneration.ts
Normal file
183
src/hooks/useAIGeneration.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Custom hook for AI-powered diagram generation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useDiagramStore } from '../store/diagramStore';
|
||||||
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
|
import { useUIStore } from '../store/uiStore';
|
||||||
|
import { interpretText, analyzeImage, analyzeSVG } from '../lib/aiService';
|
||||||
|
import { parseMermaid } from '../lib/mermaidParser';
|
||||||
|
import { getLayoutedElements } from '../lib/layoutEngine';
|
||||||
|
import type { AIResponse, NodeMetadata } from '../types';
|
||||||
|
|
||||||
|
interface UseAIGenerationReturn {
|
||||||
|
generateFromText: (text: string) => Promise<void>;
|
||||||
|
generateFromImage: (imageBase64: string) => Promise<void>;
|
||||||
|
generateFromSVG: (svgContent: string) => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for generating diagrams using AI
|
||||||
|
*/
|
||||||
|
export function useAIGeneration(): UseAIGenerationReturn {
|
||||||
|
const { setNodes, setEdges, setSourceCode } = useDiagramStore();
|
||||||
|
const { ollamaUrl, modelName, aiMode, onlineProvider, apiKey } = useSettingsStore();
|
||||||
|
const { isLoading, error, setLoading, setError } = useUIStore();
|
||||||
|
|
||||||
|
const processAIResponse = useCallback(
|
||||||
|
(result: AIResponse) => {
|
||||||
|
if (!result.success || !result.mermaidCode) {
|
||||||
|
throw new Error(result.error || 'Failed to generate diagram');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceCode(result.mermaidCode);
|
||||||
|
const { nodes: parsedNodes, edges: parsedEdges } = parseMermaid(result.mermaidCode);
|
||||||
|
|
||||||
|
// Attach metadata if available
|
||||||
|
if (result.metadata) {
|
||||||
|
parsedNodes.forEach((node) => {
|
||||||
|
const label = (node.data.label as string) || '';
|
||||||
|
if (label && result.metadata && result.metadata[label]) {
|
||||||
|
node.data.metadata = result.metadata[label] as NodeMetadata;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
||||||
|
parsedNodes,
|
||||||
|
parsedEdges
|
||||||
|
);
|
||||||
|
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
},
|
||||||
|
[setNodes, setEdges, setSourceCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const validateSettings = useCallback(() => {
|
||||||
|
if (aiMode === 'offline' && !ollamaUrl) {
|
||||||
|
throw new Error('Please configure Ollama URL in settings');
|
||||||
|
}
|
||||||
|
if (aiMode === 'online' && !apiKey) {
|
||||||
|
throw new Error('Please configure API key in settings');
|
||||||
|
}
|
||||||
|
}, [aiMode, ollamaUrl, apiKey]);
|
||||||
|
|
||||||
|
const generateFromText = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateSettings();
|
||||||
|
const result = await interpretText(
|
||||||
|
text,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
processAIResponse(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to generate diagram');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
validateSettings,
|
||||||
|
processAIResponse,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateFromImage = useCallback(
|
||||||
|
async (imageBase64: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateSettings();
|
||||||
|
const result = await analyzeImage(
|
||||||
|
imageBase64,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
processAIResponse(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to analyze image');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
validateSettings,
|
||||||
|
processAIResponse,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateFromSVG = useCallback(
|
||||||
|
async (svgContent: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateSettings();
|
||||||
|
const result = await analyzeSVG(
|
||||||
|
svgContent,
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
processAIResponse(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to analyze SVG');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
ollamaUrl,
|
||||||
|
modelName,
|
||||||
|
aiMode,
|
||||||
|
onlineProvider,
|
||||||
|
apiKey,
|
||||||
|
validateSettings,
|
||||||
|
processAIResponse,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generateFromText,
|
||||||
|
generateFromImage,
|
||||||
|
generateFromSVG,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
88
src/hooks/useKeyboardShortcuts.ts
Normal file
88
src/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/**
|
||||||
|
* Custom hook for keyboard shortcuts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface ShortcutConfig {
|
||||||
|
key: string;
|
||||||
|
ctrl?: boolean;
|
||||||
|
meta?: boolean;
|
||||||
|
shift?: boolean;
|
||||||
|
alt?: boolean;
|
||||||
|
action: () => void;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseKeyboardShortcutsOptions {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for registering keyboard shortcuts
|
||||||
|
*/
|
||||||
|
export function useKeyboardShortcuts(
|
||||||
|
shortcuts: ShortcutConfig[],
|
||||||
|
options: UseKeyboardShortcutsOptions = {}
|
||||||
|
) {
|
||||||
|
const { enabled = true } = options;
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
// Ignore shortcuts when typing in input fields
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const shortcut of shortcuts) {
|
||||||
|
const ctrlOrMeta = shortcut.ctrl || shortcut.meta;
|
||||||
|
const ctrlMatch = ctrlOrMeta ? event.ctrlKey || event.metaKey : true;
|
||||||
|
const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
|
||||||
|
const altMatch = shortcut.alt ? event.altKey : !event.altKey;
|
||||||
|
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
|
||||||
|
|
||||||
|
if (ctrlMatch && shiftMatch && altMatch && keyMatch) {
|
||||||
|
event.preventDefault();
|
||||||
|
shortcut.action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[shortcuts, enabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleKeyDown, enabled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display string for a shortcut
|
||||||
|
*/
|
||||||
|
export function getShortcutDisplay(shortcut: ShortcutConfig): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const isMac = typeof navigator !== 'undefined' && navigator.platform.includes('Mac');
|
||||||
|
|
||||||
|
if (shortcut.ctrl || shortcut.meta) {
|
||||||
|
parts.push(isMac ? '⌘' : 'Ctrl');
|
||||||
|
}
|
||||||
|
if (shortcut.shift) {
|
||||||
|
parts.push(isMac ? '⇧' : 'Shift');
|
||||||
|
}
|
||||||
|
if (shortcut.alt) {
|
||||||
|
parts.push(isMac ? '⌥' : 'Alt');
|
||||||
|
}
|
||||||
|
parts.push(shortcut.key.toUpperCase());
|
||||||
|
|
||||||
|
return parts.join(isMac ? '' : '+');
|
||||||
|
}
|
||||||
87
src/hooks/useVisualOrganizer.ts
Normal file
87
src/hooks/useVisualOrganizer.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useDiagramStore } from '../store';
|
||||||
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
|
import { VisualOrganizer, 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;
|
||||||
65
src/index.css
Normal file
65
src/index.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@xyflow/react/dist/style.css";
|
||||||
|
@import "./styles/theme.css";
|
||||||
|
@import "./styles/ui.css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", system-ui, sans-serif;
|
||||||
|
--font-display: "Space Grotesk", "Inter", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force class-based dark mode for Tailwind v4 */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ReactFlow Theming Overrides */
|
||||||
|
.react-flow__background {
|
||||||
|
background-color: var(--bg-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border: 1px solid var(--glass-border) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button {
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border: none !important;
|
||||||
|
fill: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:hover {
|
||||||
|
background: var(--interactive-hover) !important;
|
||||||
|
fill: var(--text-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default group node container styling - keep inner nodes intact */
|
||||||
|
.react-flow__node-group {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove the default white background and black border from ALL node wrappers */
|
||||||
|
.react-flow__node-default,
|
||||||
|
.react-flow__node-input,
|
||||||
|
.react-flow__node-output {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
154
src/lib/__tests__/aiService.test.ts
Normal file
154
src/lib/__tests__/aiService.test.ts
Normal file
|
|
@ -0,0 +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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
105
src/lib/__tests__/mermaidParser.test.ts
Normal file
105
src/lib/__tests__/mermaidParser.test.ts
Normal file
|
|
@ -0,0 +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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
543
src/lib/aiService.ts
Normal file
543
src/lib/aiService.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
export interface NodeMetadata {
|
||||||
|
techStack: string[];
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIResponse {
|
||||||
|
success: boolean;
|
||||||
|
mermaidCode?: string;
|
||||||
|
metadata?: Record<string, NodeMetadata>; // Keyed by node label
|
||||||
|
analysis?: any; // For visual analysis results
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { webLlmService } from './webLlmService';
|
||||||
|
import { visionService } from './visionService';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
|
||||||
|
"metadata": {
|
||||||
|
"NodeLabel": {
|
||||||
|
"techStack": ["Technologies used"],
|
||||||
|
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
|
||||||
|
"description": "Concise technical responsibility"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHITECTURAL RULES:
|
||||||
|
1. Use 'flowchart TD' or 'flowchart LR'.
|
||||||
|
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
|
||||||
|
3. Labels must match the keys in the 'metadata' object EXACTLY.
|
||||||
|
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
|
||||||
|
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
|
||||||
|
6. Escape double quotes inside the mermaid string correctly.
|
||||||
|
|
||||||
|
DIAGRAM QUALITY RULES:
|
||||||
|
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
|
||||||
|
8. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
|
||||||
|
9. Use CYLINDER shapes [(Database)] for data stores.
|
||||||
|
10. Use ROUNDED shapes (Process) for human/manual tasks.
|
||||||
|
11. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
|
||||||
|
12. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
|
||||||
|
13. Use subgraphs/swimlanes to group related components by team, role, or domain.
|
||||||
|
14. Ensure consistent node shapes within each swimlane/subgraph.`;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff retry wrapper for fetch.
|
||||||
|
*/
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
retries: number = 2,
|
||||||
|
backoff: number = 1000
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
// Retry on 429 (Too Many Requests) or 5xx (Server Errors)
|
||||||
|
if (!response.ok && (response.status === 429 || response.status >= 500)) {
|
||||||
|
throw new Error(`Retryable error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (retries > 0) {
|
||||||
|
console.warn(`Fetch failed, retrying in ${backoff}ms... (${retries} retries left)`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, backoff));
|
||||||
|
return fetchWithRetry(url, options, retries - 1, backoff * 2);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callOnlineAI(
|
||||||
|
provider: 'openai' | 'gemini' | 'ollama-cloud',
|
||||||
|
apiKey: string,
|
||||||
|
ollamaUrl: string,
|
||||||
|
messages: any[],
|
||||||
|
customSystemPrompt?: string
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const activePrompt = customSystemPrompt || SYSTEM_PROMPT;
|
||||||
|
try {
|
||||||
|
let url = '';
|
||||||
|
let headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
let body: any = {};
|
||||||
|
|
||||||
|
if (provider === 'openai') {
|
||||||
|
url = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
body = {
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{ role: 'system', content: activePrompt }, ...messages],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
};
|
||||||
|
} else if (provider === 'gemini') {
|
||||||
|
// Simple Gemini API call (v1beta)
|
||||||
|
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`;
|
||||||
|
body = {
|
||||||
|
contents: [{
|
||||||
|
parts: [{
|
||||||
|
text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}`
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
generationConfig: { responseMimeType: 'application/json' }
|
||||||
|
};
|
||||||
|
} else if (provider === 'ollama-cloud') {
|
||||||
|
url = `${ollamaUrl}/api/chat`;
|
||||||
|
body = {
|
||||||
|
model: 'llava', // Default for vision
|
||||||
|
messages: [{ role: 'system', content: activePrompt }, ...messages],
|
||||||
|
format: 'json',
|
||||||
|
stream: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithRetry(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = `${provider} error: ${response.status} ${response.statusText}`;
|
||||||
|
if (response.status === 401) errorMsg = 'Invalid API Key. Please check your settings.';
|
||||||
|
if (response.status === 429) errorMsg = 'Rate limit exceeded. Please try again later.';
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let content = '{}';
|
||||||
|
|
||||||
|
if (provider === 'openai') {
|
||||||
|
content = data.choices[0].message.content;
|
||||||
|
} else if (provider === 'gemini') {
|
||||||
|
content = data.candidates[0].content.parts[0].text;
|
||||||
|
} else {
|
||||||
|
content = data.message?.content || '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mermaidCode: parsed.mermaidCode,
|
||||||
|
metadata: parsed.metadata
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Online model failure'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callLocalAI(
|
||||||
|
ollamaUrl: string,
|
||||||
|
model: string,
|
||||||
|
messages: any[],
|
||||||
|
customSystemPrompt?: string
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const activePrompt = customSystemPrompt || SYSTEM_PROMPT;
|
||||||
|
try {
|
||||||
|
const response = await fetchWithRetry(`${ollamaUrl}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: activePrompt },
|
||||||
|
...messages
|
||||||
|
],
|
||||||
|
format: 'json',
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.2,
|
||||||
|
num_predict: 2000,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Local model error (${response.status}): ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const content = data.message?.content || '{}';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Strip markdown code blocks if present (```json ... ```)
|
||||||
|
let cleanContent = content.trim();
|
||||||
|
if (cleanContent.startsWith('```')) {
|
||||||
|
cleanContent = cleanContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleanContent);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mermaidCode: parsed.mermaidCode,
|
||||||
|
metadata: parsed.metadata,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse model response:', content);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Model returned invalid JSON format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let msg = error instanceof Error ? error.message : 'Unknown local AI error';
|
||||||
|
if (msg.includes('Failed to fetch') || msg.includes('ECONNREFUSED')) {
|
||||||
|
msg = 'Could not connect to Ollama. Is it running locally on the specified URL?';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function callBrowserAI(
|
||||||
|
messages: any[],
|
||||||
|
customSystemPrompt?: string
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const activePrompt = customSystemPrompt || SYSTEM_PROMPT;
|
||||||
|
try {
|
||||||
|
if (!webLlmService.getStatus().isReady) {
|
||||||
|
throw new Error('Browser model is not loaded. Please initialize it in Settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Vision Processing Pipeline ---
|
||||||
|
let processedMessages = [];
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
let content = msg.content;
|
||||||
|
|
||||||
|
if (msg.images && msg.images.length > 0) {
|
||||||
|
if (!visionService.getStatus().isReady) {
|
||||||
|
throw new Error('Vision model is not loaded. Please initialize it in Settings (Browser Tab).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the first image
|
||||||
|
// Assuming msg.images[0] is base64 string
|
||||||
|
const imageDescription = await visionService.analyzeImage(msg.images[0]);
|
||||||
|
|
||||||
|
// Augment the prompt with the description
|
||||||
|
content = `${content}\n\n[VISUAL CONTEXT FROM IMAGE]:\n${imageDescription}\n\n(Use this visual description to generate the Mermaid code.)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedMessages.push({
|
||||||
|
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') ? msg.role : 'user',
|
||||||
|
content: content
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullMessages = [
|
||||||
|
{ role: 'system' as const, content: activePrompt },
|
||||||
|
...processedMessages
|
||||||
|
];
|
||||||
|
|
||||||
|
const generator = await webLlmService.chat(fullMessages);
|
||||||
|
let fullContent = "";
|
||||||
|
|
||||||
|
for await (const chunk of generator) {
|
||||||
|
fullContent += chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
let cleanContent = fullContent.trim();
|
||||||
|
if (cleanContent.startsWith('```')) {
|
||||||
|
cleanContent = cleanContent.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleanContent);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mermaidCode: parsed.mermaidCode,
|
||||||
|
metadata: parsed.metadata,
|
||||||
|
analysis: parsed.analysis // Forward analysis field if present
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Browser model logic failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
|
||||||
|
- Focus on the main data flow (max 5-7 nodes).
|
||||||
|
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
|
||||||
|
- Use diamond {Decision?} for approval/review steps.
|
||||||
|
- Minimal metadata (role only).`;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
|
||||||
|
- Include all subsystems, queues, workers, and external services.
|
||||||
|
- Use swimlanes (subgraphs) to group components by role/domain.
|
||||||
|
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
|
||||||
|
- Use diamond {Decision?} for approval/review steps.
|
||||||
|
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
|
||||||
|
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
|
||||||
|
- Include feedback loops connecting outputs back to inputs where logical.
|
||||||
|
- Detailed metadata (techStack, role, description).`;
|
||||||
|
|
||||||
|
function getSystemPrompt(complexity: 'simple' | 'complex') {
|
||||||
|
return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeImage(
|
||||||
|
imageBase64: string,
|
||||||
|
ollamaUrl: string,
|
||||||
|
model: string,
|
||||||
|
aiMode: 'online' | 'offline' | 'browser' = 'offline',
|
||||||
|
onlineProvider?: 'openai' | 'gemini' | 'ollama-cloud' | 'browser',
|
||||||
|
apiKey?: string,
|
||||||
|
complexity: 'simple' | 'complex' = 'simple'
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const rawBase64 = imageBase64.includes(',') ? imageBase64.split(',')[1] : imageBase64;
|
||||||
|
const prompt = getSystemPrompt(complexity) + "\n" + SYSTEM_PROMPT.split('\n').slice(16).join('\n'); // Append JSON rules
|
||||||
|
|
||||||
|
const messages = [{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Analyze this system design diagram. Return MermaidJS and technical metadata.',
|
||||||
|
images: [rawBase64]
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (aiMode === 'online' && onlineProvider) {
|
||||||
|
return callOnlineAI(onlineProvider as any, apiKey || '', ollamaUrl, messages, prompt);
|
||||||
|
}
|
||||||
|
if (aiMode === 'browser') {
|
||||||
|
return callBrowserAI(messages, prompt);
|
||||||
|
}
|
||||||
|
return callLocalAI(ollamaUrl, model, messages, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function interpretText(
|
||||||
|
text: string,
|
||||||
|
ollamaUrl: string,
|
||||||
|
model: string,
|
||||||
|
aiMode: 'online' | 'offline' | 'browser' = 'offline',
|
||||||
|
onlineProvider?: 'openai' | 'gemini' | 'ollama-cloud' | 'browser',
|
||||||
|
apiKey?: string,
|
||||||
|
complexity: 'simple' | 'complex' = 'simple'
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const prompt = getSystemPrompt(complexity) + "\n" + SYSTEM_PROMPT.split('\n').slice(16).join('\n'); // Append JSON rules
|
||||||
|
|
||||||
|
const messages = [{
|
||||||
|
role: 'user',
|
||||||
|
content: `Create a system design based on this description: ${text}`,
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (aiMode === 'online' && onlineProvider) {
|
||||||
|
return callOnlineAI(onlineProvider as any, apiKey || '', ollamaUrl, messages, prompt);
|
||||||
|
}
|
||||||
|
if (aiMode === 'browser') {
|
||||||
|
return callBrowserAI(messages, prompt);
|
||||||
|
}
|
||||||
|
return callLocalAI(ollamaUrl, model, messages, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeSVG(
|
||||||
|
svgContent: string,
|
||||||
|
ollamaUrl: string,
|
||||||
|
model: string,
|
||||||
|
aiMode: 'online' | 'offline' | 'browser' = 'offline',
|
||||||
|
onlineProvider?: 'openai' | 'gemini' | 'ollama-cloud' | 'browser',
|
||||||
|
apiKey?: string
|
||||||
|
): Promise<AIResponse> {
|
||||||
|
const messages = [{
|
||||||
|
role: 'user',
|
||||||
|
content: `Analyze this SVG architecture: ${svgContent}`,
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (aiMode === 'online' && onlineProvider) {
|
||||||
|
return callOnlineAI(onlineProvider as any, apiKey || '', ollamaUrl, messages);
|
||||||
|
}
|
||||||
|
if (aiMode === 'browser') {
|
||||||
|
return callBrowserAI(messages);
|
||||||
|
}
|
||||||
|
return callLocalAI(ollamaUrl, model, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
|
||||||
|
Your task is to analyze the provided Mermaid flowchart code and either:
|
||||||
|
1. Fix any syntax errors that prevent it from rendering.
|
||||||
|
2. Improve the logical flow or visual clarity if the syntax is already correct.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
|
||||||
|
"explanation": "Briefly explain what was fixed or improved."
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Maintain the original intent of the diagram.
|
||||||
|
- Use best practices for Mermaid layout and labeling.
|
||||||
|
- If the code is already perfect, return it as is but provide a positive explanation.`;
|
||||||
|
|
||||||
|
const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
|
||||||
|
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
|
||||||
|
|
||||||
|
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
|
||||||
|
|
||||||
|
EXPECTED JSON FORMAT:
|
||||||
|
{
|
||||||
|
"analysis": {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "unique-id",
|
||||||
|
"title": "Short title",
|
||||||
|
"description": "Detailed explanation",
|
||||||
|
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
|
||||||
|
"impact": "high" | "medium" | "low",
|
||||||
|
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critique": "Overall analysis",
|
||||||
|
"score": 0-100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXAMPLE RESPONSE:
|
||||||
|
{
|
||||||
|
"analysis": {
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "sug-1",
|
||||||
|
"title": "Group Database Nodes",
|
||||||
|
"description": "Several database nodes are scattered. Group them for better logical separation.",
|
||||||
|
"type": "grouping",
|
||||||
|
"impact": "high",
|
||||||
|
"fix_strategy": "group_nodes"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"critique": "The flow is generally good but lacks logical grouping for backend services.",
|
||||||
|
"score": 75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Focus on readability, flow, and logical grouping.
|
||||||
|
- Identify if nodes are too cluttered or if the flow is confusing.
|
||||||
|
- Suggest grouping for nodes that appear related based on their labels.`;
|
||||||
|
|
||||||
|
export async function suggestFix(
|
||||||
|
code: string,
|
||||||
|
ollamaUrl: string,
|
||||||
|
modelName: string,
|
||||||
|
aiMode: 'online' | 'offline' | 'browser',
|
||||||
|
onlineProvider: 'openai' | 'gemini' | 'ollama-cloud' | 'browser',
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{ success: boolean; mermaidCode?: string; explanation?: string; error?: string }> {
|
||||||
|
const messages = [{
|
||||||
|
role: 'user',
|
||||||
|
content: `CURRENT MERMAID CODE:\n${code}\n\nPlease analyze and provide a fix or improvement.`,
|
||||||
|
}];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response: AIResponse;
|
||||||
|
if (aiMode === 'online' && onlineProvider) {
|
||||||
|
response = await callOnlineAI(onlineProvider as any, apiKey || '', ollamaUrl, messages, SUGGEST_PROMPT);
|
||||||
|
} else if (aiMode === 'browser') {
|
||||||
|
response = await callBrowserAI(messages, SUGGEST_PROMPT);
|
||||||
|
} else {
|
||||||
|
response = await callLocalAI(ollamaUrl, modelName, messages, SUGGEST_PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.success) throw new Error(response.error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
mermaidCode: response.mermaidCode,
|
||||||
|
explanation: (response as any).explanation || 'Code improved.'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Fix suggestion failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeVisualLayout(
|
||||||
|
nodes: any[],
|
||||||
|
edges: any[],
|
||||||
|
metrics: any,
|
||||||
|
ollamaUrl: string,
|
||||||
|
modelName: string,
|
||||||
|
aiMode: 'online' | 'offline' | 'browser',
|
||||||
|
onlineProvider: 'openai' | 'gemini' | 'ollama-cloud' | 'browser',
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{ success: boolean; analysis?: any; error?: string }> {
|
||||||
|
const context = {
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
edgeCount: edges.length,
|
||||||
|
nodeLabels: nodes.map(n => n.data?.label || n.id),
|
||||||
|
metrics: metrics
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [{
|
||||||
|
role: 'user',
|
||||||
|
content: `ANALYZE THIS DIAGRAM LAYOUT:\n${JSON.stringify(context, null, 2)}\n\nProvide visual improvement suggestions.`,
|
||||||
|
}];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response: AIResponse;
|
||||||
|
if (aiMode === 'online' && onlineProvider) {
|
||||||
|
response = await callOnlineAI(onlineProvider as any, apiKey || '', ollamaUrl, messages, VISUAL_ANALYSIS_PROMPT);
|
||||||
|
} else if (aiMode === 'browser') {
|
||||||
|
response = await callBrowserAI(messages, VISUAL_ANALYSIS_PROMPT);
|
||||||
|
} else {
|
||||||
|
response = await callLocalAI(ollamaUrl, modelName, messages, VISUAL_ANALYSIS_PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.success) throw new Error(response.error);
|
||||||
|
|
||||||
|
// The AI response parsing logic in callLocalAI/callOnlineAI assigns unknown JSON fields to the object
|
||||||
|
// We expect 'analysis' field in the JSON
|
||||||
|
const analysis = (response as any).analysis || (response as any).visualAnalysis;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
analysis: analysis
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Visual analysis failed'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/lib/exportUtils.ts
Normal file
199
src/lib/exportUtils.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import { toPng } from 'html-to-image';
|
||||||
|
import { type Node, type Edge } from '../store';
|
||||||
|
|
||||||
|
export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dataUrl = await toPng(element, {
|
||||||
|
backgroundColor: '#020617',
|
||||||
|
quality: 1,
|
||||||
|
pixelRatio: 3,
|
||||||
|
filter: (node) => {
|
||||||
|
const className = node.className?.toString() || '';
|
||||||
|
return !className.includes('react-flow__controls') &&
|
||||||
|
!className.includes('react-flow__minimap') &&
|
||||||
|
!className.includes('react-flow__panel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile(dataUrl, `diagram-${getTimestamp()}.png`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export PNG:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { toJpeg } = await import('html-to-image');
|
||||||
|
const dataUrl = await toJpeg(element, {
|
||||||
|
backgroundColor: '#020617',
|
||||||
|
quality: 0.95,
|
||||||
|
pixelRatio: 2,
|
||||||
|
filter: (node) => {
|
||||||
|
const className = node.className?.toString() || '';
|
||||||
|
return !className.includes('react-flow__controls') &&
|
||||||
|
!className.includes('react-flow__minimap') &&
|
||||||
|
!className.includes('react-flow__panel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadFile(dataUrl, `diagram-${getTimestamp()}.jpg`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export JPG:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToSvg(nodes: Node[], _edges: Edge[]): void {
|
||||||
|
// Basic SVG export logic (simplified for React Flow)
|
||||||
|
const svgContent = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800">
|
||||||
|
<rect width="100%" height="100%" fill="#020617" />
|
||||||
|
<text x="20" y="40" fill="white" font-family="sans-serif" font-size="16">Architecture Diagram Export (SVG)</text>
|
||||||
|
<!-- Simplified representation -->
|
||||||
|
<g transform="translate(50,100)">
|
||||||
|
${nodes.map(n => `<rect x="${n.position.x}" y="${n.position.y}" width="150" height="60" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2" />`).join('')}
|
||||||
|
${nodes.map(n => `<text x="${n.position.x + 75}" y="${n.position.y + 35}" fill="white" font-size="12" text-anchor="middle" font-family="sans-serif">${(n.data as any).label || n.id}</text>`).join('')}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
downloadFile(url, `diagram-${getTimestamp()}.svg`);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToTxt(nodes: Node[], edges: Edge[]): void {
|
||||||
|
let txt = `Architecture Diagram Summary\n`;
|
||||||
|
txt += `Generated: ${new Date().toLocaleString()}\n`;
|
||||||
|
txt += `------------------------------------\n\n`;
|
||||||
|
|
||||||
|
txt += `ENTITIES (${nodes.filter(n => n.type !== 'group').length}):\n`;
|
||||||
|
nodes.filter(n => n.type !== 'group').forEach(n => {
|
||||||
|
txt += `- [${n.type?.toUpperCase() || 'DEFAULT'}] ${(n.data as any).label || n.id}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
txt += `\nRELATIONS (${edges.length}):\n`;
|
||||||
|
edges.forEach(e => {
|
||||||
|
const sourceLabel = nodes.find(n => n.id === e.source)?.data.label || e.source;
|
||||||
|
const targetLabel = nodes.find(n => n.id === e.target)?.data.label || e.target;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToJson(nodes: Node[], edges: Edge[]): void {
|
||||||
|
const data = {
|
||||||
|
version: '1.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
nodes: nodes.map(n => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
position: n.position,
|
||||||
|
data: n.data,
|
||||||
|
})),
|
||||||
|
edges: edges.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: e.label,
|
||||||
|
type: e.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
|
||||||
|
let mermaid = 'flowchart TD\n\n';
|
||||||
|
|
||||||
|
// Add styling
|
||||||
|
mermaid += ' %% Styling\n';
|
||||||
|
mermaid += ' classDef start fill:#10b981,stroke:#10b981,color:#fff\n';
|
||||||
|
mermaid += ' classDef end fill:#ef4444,stroke:#ef4444,color:#fff\n';
|
||||||
|
mermaid += ' classDef database fill:#10b981,stroke:#10b981,color:#fff\n';
|
||||||
|
mermaid += ' classDef server fill:#3b82f6,stroke:#3b82f6,color:#fff\n';
|
||||||
|
mermaid += ' classDef decision fill:#8b5cf6,stroke:#8b5cf6,color:#fff\n\n';
|
||||||
|
|
||||||
|
// Export nodes
|
||||||
|
mermaid += ' %% Nodes\n';
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const label = (node.data as { label?: string })?.label || node.id;
|
||||||
|
const safeLabel = label.replace(/"/g, "'");
|
||||||
|
let shape = '';
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'start':
|
||||||
|
case 'startNode':
|
||||||
|
shape = `(["${safeLabel}"])`;
|
||||||
|
break;
|
||||||
|
case 'end':
|
||||||
|
case 'endNode':
|
||||||
|
shape = `(["${safeLabel}"])`;
|
||||||
|
break;
|
||||||
|
case 'decision':
|
||||||
|
case 'decisionNode':
|
||||||
|
shape = `{"${safeLabel}"}`;
|
||||||
|
break;
|
||||||
|
case 'database':
|
||||||
|
case 'databaseNode':
|
||||||
|
shape = `[("${safeLabel}")]`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
shape = `["${safeLabel}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
mermaid += ` ${node.id}${shape}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
mermaid += '\n %% Connections\n';
|
||||||
|
|
||||||
|
// Export edges
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
const label = edge.label ? `|${edge.label}|` : '';
|
||||||
|
mermaid += ` ${edge.source} -->${label} ${edge.target}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply classes
|
||||||
|
mermaid += '\n %% Apply styles\n';
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type?.includes('start')) mermaid += ` class ${node.id} start\n`;
|
||||||
|
if (node.type?.includes('end')) mermaid += ` class ${node.id} end\n`;
|
||||||
|
if (node.type?.includes('database')) mermaid += ` class ${node.id} database\n`;
|
||||||
|
if (node.type?.includes('server')) mermaid += ` class ${node.id} server\n`;
|
||||||
|
if (node.type?.includes('decision')) mermaid += ` class ${node.id} decision\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return mermaid;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
293
src/lib/layoutEngine.ts
Normal file
293
src/lib/layoutEngine.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import dagre from 'dagre';
|
||||||
|
import { type Node, type Edge } from '../store';
|
||||||
|
|
||||||
|
const nodeWidth = 180;
|
||||||
|
const nodeHeight = 60;
|
||||||
|
const groupPadding = 40;
|
||||||
|
const groupTitleHeight = 50;
|
||||||
|
const groupGap = 60; // Gap between swimlane groups
|
||||||
|
|
||||||
|
export interface LayoutOptions {
|
||||||
|
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||||
|
nodeSpacing: number;
|
||||||
|
rankSpacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: LayoutOptions = {
|
||||||
|
direction: 'TB',
|
||||||
|
nodeSpacing: 40,
|
||||||
|
rankSpacing: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 + groupGap;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
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 + groupPadding;
|
||||||
|
child.position.y = child.position.y - minY + groupPadding + groupTitleHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
const groupWidth = contentWidth + groupPadding * 2;
|
||||||
|
const groupHeight = contentHeight + groupPadding * 2 + groupTitleHeight;
|
||||||
|
|
||||||
|
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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
|
||||||
|
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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
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 : nodeWidth;
|
||||||
|
const h = node.type === 'decision' ? 90 : nodeHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
|
||||||
|
targetPosition: isHorizontal ? 'left' : 'top',
|
||||||
|
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||||
|
} as Node;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges };
|
||||||
|
}
|
||||||
352
src/lib/mermaidParser.ts
Normal file
352
src/lib/mermaidParser.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
import mermaid from 'mermaid';
|
||||||
|
import { type Node, type Edge } from '../store';
|
||||||
|
|
||||||
|
// Initialize mermaid
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: 'default',
|
||||||
|
securityLevel: 'loose',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ParsedNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: 'start' | 'end' | 'default' | 'decision' | 'process' | 'database' | 'group' | 'client' | 'server';
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize node labels by removing HTML tags and normalizing whitespace
|
||||||
|
*/
|
||||||
|
function sanitizeLabel(label: string): string {
|
||||||
|
return label
|
||||||
|
.replace(/<br\s*\/?>/gi, ' ') // Replace <br/> with space
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove all other HTML tags
|
||||||
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if label indicates a decision/approval step
|
||||||
|
*/
|
||||||
|
function isDecisionLabel(label: string): boolean {
|
||||||
|
const decisionKeywords = ['review', 'approve', 'decision', 'verify', 'check', 'validate', 'confirm', '?'];
|
||||||
|
const lowerLabel = label.toLowerCase();
|
||||||
|
return decisionKeywords.some(keyword => lowerLabel.includes(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocess mermaid code to handle common issues
|
||||||
|
*/
|
||||||
|
function preprocessMermaidCode(code: string): string {
|
||||||
|
return code
|
||||||
|
// Remove %%{init:...}%% directives that may cause issues
|
||||||
|
.replace(/%%\{init:[^}]*\}%%/g, '')
|
||||||
|
// Convert <br/>, <br>, <br /> to spaces in node labels
|
||||||
|
.replace(/<br\s*\/?>/gi, ' ')
|
||||||
|
// Normalize line endings
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
// Remove empty lines at start
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||||
|
try {
|
||||||
|
// Preprocess the code to handle common issues
|
||||||
|
const cleanedCode = preprocessMermaidCode(mermaidCode);
|
||||||
|
|
||||||
|
// Validate syntax first
|
||||||
|
await mermaid.parse(cleanedCode);
|
||||||
|
|
||||||
|
// Get the diagram definition
|
||||||
|
// @ts-ignore - getDiagram is internal but necessary for AST access
|
||||||
|
const diagram = await mermaid.mermaidAPI.getDiagramFromText(cleanedCode);
|
||||||
|
|
||||||
|
// Access the internal database for flowcharts
|
||||||
|
const db = diagram.db;
|
||||||
|
|
||||||
|
// Flowchart extraction
|
||||||
|
// Note: Different diagram types have different DB structures. This covers 'flowchart' and 'graph'.
|
||||||
|
const vertices = (db as any).getVertices?.() || {};
|
||||||
|
const edgesData = (db as any).getEdges?.() || [];
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
const groupColors = ['#fef3c7', '#dbeafe', '#dcfce7', '#fce7f3', '#e0e7ff'];
|
||||||
|
let groupIndex = 0;
|
||||||
|
|
||||||
|
// REVISE: Retrieving subgraphs from DB
|
||||||
|
const subgraphs = (db as any).getSubGraphs?.() || []; // { id, title, nodes: [ids] }
|
||||||
|
|
||||||
|
// Create Group Nodes first
|
||||||
|
for (const sub of subgraphs) {
|
||||||
|
nodes.push({
|
||||||
|
id: sub.id,
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: sub.title,
|
||||||
|
color: groupColors[groupIndex++ % groupColors.length]
|
||||||
|
},
|
||||||
|
style: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Nodes and Groups logic combined
|
||||||
|
for (const [id, vertex] of Object.entries(vertices) as any[]) {
|
||||||
|
// vertex: { id, text, type, styles, classes, ... }
|
||||||
|
const rawLabel = vertex.text || id;
|
||||||
|
const label = sanitizeLabel(rawLabel); // Clean HTML tags
|
||||||
|
let type: ParsedNode['type'] = 'default';
|
||||||
|
const lowerLabel = label.toLowerCase();
|
||||||
|
|
||||||
|
// Infer type from shape/label overlap
|
||||||
|
if (vertex.type === 'cylinder' || lowerLabel.includes('db') || lowerLabel.includes('database')) {
|
||||||
|
type = 'database';
|
||||||
|
} else if (vertex.type === 'diamond' || isDecisionLabel(label)) {
|
||||||
|
type = 'decision';
|
||||||
|
} else if (lowerLabel.includes('start') || lowerLabel.includes('begin')) {
|
||||||
|
type = 'start';
|
||||||
|
} else if (lowerLabel.includes('end') || lowerLabel.includes('stop')) {
|
||||||
|
type = 'end';
|
||||||
|
} else {
|
||||||
|
// Check heuristics
|
||||||
|
if (lowerLabel.includes('client') || lowerLabel.includes('user')) type = 'client';
|
||||||
|
else if (lowerLabel.includes('server') || lowerLabel.includes('api')) type = 'server';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if node belongs to a subgraph
|
||||||
|
let parentId = undefined;
|
||||||
|
for (const sub of subgraphs) {
|
||||||
|
if (sub.nodes.includes(id)) {
|
||||||
|
parentId = sub.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine category for filtering
|
||||||
|
let category = 'filter-server';
|
||||||
|
if (type === 'database') category = 'filter-db';
|
||||||
|
else if (type === 'client') category = 'filter-client';
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label, category }, // Use sanitized label
|
||||||
|
parentId: parentId,
|
||||||
|
extent: parentId ? 'parent' : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Edges
|
||||||
|
edgesData.forEach((e: any, i: number) => {
|
||||||
|
edges.push({
|
||||||
|
id: `e${e.start}-${e.end}-${i}`,
|
||||||
|
source: e.start,
|
||||||
|
target: e.end,
|
||||||
|
label: e.text,
|
||||||
|
animated: e.stroke === 'dotted', // Heuristic
|
||||||
|
style: {
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined
|
||||||
|
},
|
||||||
|
labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 },
|
||||||
|
labelBgStyle: { fill: '#ffffff', fillOpacity: 0.9 },
|
||||||
|
labelBgPadding: [6, 4],
|
||||||
|
labelBgBorderRadius: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we got meaningful results: if we have edges but no non-group nodes, fallback to regex
|
||||||
|
const nonGroupNodes = nodes.filter(n => n.type !== 'group');
|
||||||
|
if (edges.length > 0 && nonGroupNodes.length === 0) {
|
||||||
|
console.warn('[MermaidParser] Mermaid API returned edges but no nodes, falling back to regex');
|
||||||
|
return parseMermaidRegex(mermaidCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MermaidParser] Mermaid API: ${nodes.length} nodes, ${edges.length} edges`);
|
||||||
|
return { nodes, edges };
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[MermaidParser] Mermaid API failed, falling back to regex:", e);
|
||||||
|
// Fallback to regex if mermaid API fails or isn't a flowchart
|
||||||
|
return parseMermaidRegex(mermaidCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved regex fallback parser
|
||||||
|
function parseMermaidRegex(mermaidCode: string): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
// Preprocess the code first
|
||||||
|
const cleanedCode = preprocessMermaidCode(mermaidCode);
|
||||||
|
const lines = cleanedCode.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('%%'));
|
||||||
|
|
||||||
|
const nodeMap = new Map<string, any>();
|
||||||
|
const parsedEdges: any[] = [];
|
||||||
|
const groups: any[] = [];
|
||||||
|
let currentGroup: any | null = null;
|
||||||
|
const groupColors = ['#fef3c7', '#dbeafe', '#dcfce7', '#fce7f3', '#e0e7ff'];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip flowchart/graph declaration
|
||||||
|
if (line.match(/^(flowchart|graph)\s+/i)) continue;
|
||||||
|
|
||||||
|
// Subgraph start - handle multiple formats
|
||||||
|
const subgraphMatch = line.match(/^subgraph\s+(\w+)\s*\[([^\]]+)\]/i) ||
|
||||||
|
line.match(/^subgraph\s+(\w+)\s*\[\s*"([^"]+)"\s*\]/i) ||
|
||||||
|
line.match(/^subgraph\s+(\w+)/i);
|
||||||
|
if (subgraphMatch) {
|
||||||
|
currentGroup = {
|
||||||
|
id: subgraphMatch[1],
|
||||||
|
label: sanitizeLabel(subgraphMatch[2] || subgraphMatch[1]),
|
||||||
|
nodes: []
|
||||||
|
};
|
||||||
|
groups.push(currentGroup);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.match(/^end$/i)) {
|
||||||
|
currentGroup = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced node definition patterns - handle all bracket types
|
||||||
|
// [text], (text), {text}, ((text)), ([text]), [(text)], [[text]], [/text/], [\text\], etc.
|
||||||
|
const nodePatterns = [
|
||||||
|
// Standard brackets: A[text], A(text), A{text}
|
||||||
|
/(\w+)\s*\[([^\]]+)\]/g, // [square]
|
||||||
|
/(\w+)\s*\(([^)]+)\)/g, // (rounded)
|
||||||
|
/(\w+)\s*\{([^}]+)\}/g, // {diamond}
|
||||||
|
// Special brackets: A[(text)], A([text]), A((text)), A[[text]]
|
||||||
|
/(\w+)\s*\[\(([^)]+)\)\]/g, // [(cylinder)]
|
||||||
|
/(\w+)\s*\(\[([^\]]+)\]\)/g, // ([stadium])
|
||||||
|
/(\w+)\s*\(\(([^)]+)\)\)/g, // ((circle))
|
||||||
|
/(\w+)\s*\[\[([^\]]+)\]\]/g, // [[subroutine]]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of nodePatterns) {
|
||||||
|
let match;
|
||||||
|
const regex = new RegExp(pattern.source, 'g');
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const id = match[1];
|
||||||
|
// Skip if it looks like an edge keyword
|
||||||
|
if (['end', 'subgraph', 'flowchart', 'graph'].includes(id.toLowerCase())) continue;
|
||||||
|
|
||||||
|
const rawLabel = match[2];
|
||||||
|
const label = sanitizeLabel(rawLabel);
|
||||||
|
|
||||||
|
// Determine type based on bracket shape
|
||||||
|
let type = 'default';
|
||||||
|
if (pattern.source.includes('\\[\\(')) type = 'database'; // [(cylinder)]
|
||||||
|
else if (pattern.source.includes('\\{')) type = 'decision'; // {diamond}
|
||||||
|
else if (pattern.source.includes('\\(\\(')) type = 'start'; // ((circle))
|
||||||
|
else if (isDecisionLabel(label)) type = 'decision';
|
||||||
|
|
||||||
|
if (!nodeMap.has(id)) {
|
||||||
|
nodeMap.set(id, {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
parentId: currentGroup?.id
|
||||||
|
});
|
||||||
|
if (currentGroup) currentGroup.nodes.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced edge matching - handle all arrow types and labels
|
||||||
|
// A --> B, A --text--> B, A -.-> B, A -.text.-> B, A ==> B, A -->|text| B, A -.->|text| B
|
||||||
|
const edgePatterns = [
|
||||||
|
/(\w+)\s*-->\|([^|]*)\|\s*(\w+)/g, // A -->|text| B
|
||||||
|
/(\w+)\s*-\.->\|([^|]*)\|\s*(\w+)/g, // A -.->|text| B (dotted with pipe label) ***NEW***
|
||||||
|
/(\w+)\s*--\s*([^->\s][^-]*?)\s*-->\s*(\w+)/g, // A --text--> B
|
||||||
|
/(\w+)\s*-\.->(\w+)/g, // A -.->B (dotted no space)
|
||||||
|
/(\w+)\s*-\.->\s*(\w+)/g, // A -.-> B (dotted)
|
||||||
|
/(\w+)\s*-\.([^.>]+)\.->\s*(\w+)/g, // A -.text.-> B (dotted with label)
|
||||||
|
/(\w+)\s*==>\s*(\w+)/g, // A ==> B (thick)
|
||||||
|
/(\w+)\s*-->\s*(\w+)/g, // A --> B (simple)
|
||||||
|
/(\w+)\s*---\s*(\w+)/g, // A --- B (no arrow)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of edgePatterns) {
|
||||||
|
let match;
|
||||||
|
const regex = new RegExp(pattern.source, 'g');
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const source = match[1];
|
||||||
|
const target = match.length > 3 ? match[3] : match[2];
|
||||||
|
const edgeLabel = match.length > 3 ? sanitizeLabel(match[2]) : undefined;
|
||||||
|
const isDotted = pattern.source.includes('-\\.');
|
||||||
|
|
||||||
|
parsedEdges.push({
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
label: edgeLabel,
|
||||||
|
dotted: isDotted
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-create nodes if they don't exist
|
||||||
|
if (!nodeMap.has(source)) {
|
||||||
|
nodeMap.set(source, { id: source, label: source, type: 'default', parentId: currentGroup?.id });
|
||||||
|
if (currentGroup) currentGroup.nodes.push(source);
|
||||||
|
}
|
||||||
|
if (!nodeMap.has(target)) {
|
||||||
|
nodeMap.set(target, { id: target, label: target, type: 'default', parentId: currentGroup?.id });
|
||||||
|
if (currentGroup) currentGroup.nodes.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to React Flow format
|
||||||
|
const nodes: Node[] = [
|
||||||
|
// Groups first
|
||||||
|
...groups.map((g, i) => ({
|
||||||
|
id: g.id,
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: g.label, color: groupColors[i % groupColors.length] },
|
||||||
|
style: {
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
// Then nodes
|
||||||
|
...Array.from(nodeMap.values()).map((n: any) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: n.label, category: 'filter-server' },
|
||||||
|
parentId: n.parentId,
|
||||||
|
extent: n.parentId ? 'parent' as const : undefined
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
const edges: Edge[] = parsedEdges.map((e, i) => ({
|
||||||
|
id: `e${i}`,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: e.label,
|
||||||
|
animated: e.dotted,
|
||||||
|
style: e.dotted ? { strokeDasharray: '5,5' } : undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`[MermaidParser] Regex fallback: ${nodes.length} nodes, ${edges.length} edges`);
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectInputType(input: string): 'mermaid' | 'natural' {
|
||||||
|
const mermaidPatterns = [
|
||||||
|
/^(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram)/im,
|
||||||
|
/-->/,
|
||||||
|
/---/,
|
||||||
|
/\[\[.*\]\]/,
|
||||||
|
/\(\(.*\)\)/,
|
||||||
|
/\{.*\}/,
|
||||||
|
/subgraph/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of mermaidPatterns) {
|
||||||
|
if (pattern.test(input)) return 'mermaid';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'natural';
|
||||||
|
}
|
||||||
30
src/lib/mermaidTest.ts
Normal file
30
src/lib/mermaidTest.ts
Normal file
|
|
@ -0,0 +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();
|
||||||
124
src/lib/visionService.ts
Normal file
124
src/lib/visionService.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
|
||||||
|
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(text, image);
|
||||||
|
|
||||||
|
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();
|
||||||
793
src/lib/visualOrganizer.ts
Normal file
793
src/lib/visualOrganizer.ts
Normal file
|
|
@ -0,0 +1,793 @@
|
||||||
|
import type { Node, Edge } from '../store';
|
||||||
|
import type {
|
||||||
|
LayoutMetrics,
|
||||||
|
VisualIssue,
|
||||||
|
LayoutSuggestion,
|
||||||
|
NodePosition
|
||||||
|
} from '../types/visualOrganization';
|
||||||
|
import { getLayoutedElements } from './layoutEngine';
|
||||||
|
|
||||||
|
export class VisualOrganizer {
|
||||||
|
private nodes: Node[];
|
||||||
|
private edges: Edge[];
|
||||||
|
|
||||||
|
constructor(nodes: Node[], edges: Edge[]) {
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.edges = edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze the current layout and identify issues
|
||||||
|
*/
|
||||||
|
analyzeLayout(): { metrics: LayoutMetrics; issues: VisualIssue[]; strengths: string[] } {
|
||||||
|
const metrics = this.calculateMetrics();
|
||||||
|
const issues = [...this.identifyIssues(), ...this.identifyStyleIssues()];
|
||||||
|
const strengths = this.identifyStrengths();
|
||||||
|
|
||||||
|
return { metrics, issues, strengths };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate layout metrics
|
||||||
|
*/
|
||||||
|
private calculateMetrics(): LayoutMetrics {
|
||||||
|
const nodeCount = this.nodes.filter(n => n.type !== 'group').length;
|
||||||
|
const edgeCount = this.edges.length;
|
||||||
|
const edgeCrossings = this.detectEdgeCrossings();
|
||||||
|
const nodeDensity = this.calculateNodeDensity();
|
||||||
|
const averageNodeSpacing = this.calculateAverageSpacing();
|
||||||
|
const visualComplexity = this.calculateVisualComplexity();
|
||||||
|
const aspectRatio = this.calculateAspectRatio();
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodeCount,
|
||||||
|
edgeCount,
|
||||||
|
edgeCrossings,
|
||||||
|
nodeDensity,
|
||||||
|
averageNodeSpacing,
|
||||||
|
visualComplexity,
|
||||||
|
aspectRatio
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect edge crossings using geometric analysis
|
||||||
|
*/
|
||||||
|
private detectEdgeCrossings(): number {
|
||||||
|
let crossings = 0;
|
||||||
|
const edges = this.edges;
|
||||||
|
|
||||||
|
for (let i = 0; i < edges.length; i++) {
|
||||||
|
for (let j = i + 1; j < edges.length; j++) {
|
||||||
|
if (this.edgesCross(edges[i], edges[j])) {
|
||||||
|
crossings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crossings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two edges cross geometrically
|
||||||
|
*/
|
||||||
|
private edgesCross(edge1: Edge, edge2: Edge): boolean {
|
||||||
|
const source1 = this.nodes.find(n => n.id === edge1.source);
|
||||||
|
const target1 = this.nodes.find(n => n.id === edge1.target);
|
||||||
|
const source2 = this.nodes.find(n => n.id === edge2.source);
|
||||||
|
const target2 = this.nodes.find(n => n.id === edge2.target);
|
||||||
|
|
||||||
|
if (!source1 || !target1 || !source2 || !target2) return false;
|
||||||
|
|
||||||
|
const p1 = { x: source1.position.x, y: source1.position.y };
|
||||||
|
const p2 = { x: target1.position.x, y: target1.position.y };
|
||||||
|
const p3 = { x: source2.position.x, y: source2.position.y };
|
||||||
|
const p4 = { x: target2.position.x, y: target2.position.y };
|
||||||
|
|
||||||
|
return this.lineSegmentsIntersect(p1, p2, p3, p4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two line segments intersect
|
||||||
|
*/
|
||||||
|
private lineSegmentsIntersect(p1: any, p2: any, p3: any, p4: any): boolean {
|
||||||
|
const ccw = (A: any, B: any, C: any) => (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
|
||||||
|
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate node density (nodes per area)
|
||||||
|
*/
|
||||||
|
private calculateNodeDensity(): number {
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
if (visibleNodes.length === 0) return 0;
|
||||||
|
|
||||||
|
const bounds = this.getNodeBounds();
|
||||||
|
const area = bounds.width * bounds.height;
|
||||||
|
|
||||||
|
return visibleNodes.length / (area / 10000); // Normalize to reasonable scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate average spacing between nodes
|
||||||
|
*/
|
||||||
|
private calculateAverageSpacing(): number {
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
if (visibleNodes.length < 2) return 0;
|
||||||
|
|
||||||
|
let totalDistance = 0;
|
||||||
|
let pairCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < visibleNodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < visibleNodes.length; j++) {
|
||||||
|
const node1 = visibleNodes[i];
|
||||||
|
const node2 = visibleNodes[j];
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(node1.position.x - node2.position.x, 2) +
|
||||||
|
Math.pow(node1.position.y - node2.position.y, 2)
|
||||||
|
);
|
||||||
|
totalDistance += distance;
|
||||||
|
pairCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairCount > 0 ? totalDistance / pairCount : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate visual complexity score (0-100)
|
||||||
|
*/
|
||||||
|
private calculateVisualComplexity(): number {
|
||||||
|
let complexity = 0;
|
||||||
|
|
||||||
|
// Edge crossings contribute heavily to complexity
|
||||||
|
complexity += this.detectEdgeCrossings() * 10;
|
||||||
|
|
||||||
|
// High node density increases complexity
|
||||||
|
complexity += this.calculateNodeDensity() * 20;
|
||||||
|
|
||||||
|
// Multiple edge types increase complexity
|
||||||
|
const uniqueEdgeTypes = new Set(this.edges.map(e => e.type || 'default')).size;
|
||||||
|
complexity += uniqueEdgeTypes * 5;
|
||||||
|
|
||||||
|
// Large number of nodes increases complexity
|
||||||
|
const nodeCount = this.nodes.filter(n => n.type !== 'group').length;
|
||||||
|
complexity += Math.max(0, nodeCount - 10) * 2;
|
||||||
|
|
||||||
|
return Math.min(100, complexity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate aspect ratio of the diagram
|
||||||
|
*/
|
||||||
|
private calculateAspectRatio(): number {
|
||||||
|
const bounds = this.getNodeBounds();
|
||||||
|
if (bounds.height === 0) return 1;
|
||||||
|
return bounds.width / bounds.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bounding box of all nodes
|
||||||
|
*/
|
||||||
|
private getNodeBounds() {
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
|
||||||
|
if (visibleNodes.length === 0) {
|
||||||
|
return { width: 1000, height: 800, minX: 0, minY: 0, maxX: 1000, maxY: 800 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
visibleNodes.forEach(node => {
|
||||||
|
minX = Math.min(minX, node.position.x);
|
||||||
|
minY = Math.min(minY, node.position.y);
|
||||||
|
maxX = Math.max(maxX, node.position.x);
|
||||||
|
maxY = Math.max(maxY, node.position.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
minX, minY, maxX, maxY
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify visual issues in the current layout
|
||||||
|
*/
|
||||||
|
private identifyIssues(): VisualIssue[] {
|
||||||
|
const issues: VisualIssue[] = [];
|
||||||
|
const metrics = this.calculateMetrics();
|
||||||
|
|
||||||
|
// Edge crossing issues
|
||||||
|
if (metrics.edgeCrossings > 5) {
|
||||||
|
issues.push({
|
||||||
|
type: 'edge-crossing',
|
||||||
|
severity: metrics.edgeCrossings > 10 ? 'high' : 'medium',
|
||||||
|
description: `High number of edge crossings (${metrics.edgeCrossings}) makes diagram difficult to follow`,
|
||||||
|
suggestedFix: 'Apply routing optimization to minimize edge intersections'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node overlap issues
|
||||||
|
const overlappingNodes = this.detectOverlappingNodes();
|
||||||
|
if (overlappingNodes.length > 0) {
|
||||||
|
issues.push({
|
||||||
|
type: 'overlap',
|
||||||
|
severity: overlappingNodes.length > 3 ? 'high' : 'medium',
|
||||||
|
description: `${overlappingNodes.length} nodes are overlapping or too close`,
|
||||||
|
affectedNodes: overlappingNodes,
|
||||||
|
suggestedFix: 'Increase spacing between nodes'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poor spacing issues
|
||||||
|
if (metrics.averageNodeSpacing < 80) {
|
||||||
|
issues.push({
|
||||||
|
type: 'poor-spacing',
|
||||||
|
severity: metrics.averageNodeSpacing < 50 ? 'high' : 'medium',
|
||||||
|
description: 'Nodes are too close together, reducing readability',
|
||||||
|
suggestedFix: 'Increase overall node spacing'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclear flow issues
|
||||||
|
if (metrics.visualComplexity > 70) {
|
||||||
|
issues.push({
|
||||||
|
type: 'unclear-flow',
|
||||||
|
severity: metrics.visualComplexity > 85 ? 'high' : 'medium',
|
||||||
|
description: 'High visual complexity makes the diagram hard to understand',
|
||||||
|
suggestedFix: 'Simplify layout by grouping related nodes and reducing crossings'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inefficient layout issues
|
||||||
|
if (metrics.aspectRatio > 3 || metrics.aspectRatio < 0.3) {
|
||||||
|
issues.push({
|
||||||
|
type: 'inefficient-layout',
|
||||||
|
severity: 'medium',
|
||||||
|
description: 'Diagram has poor aspect ratio, consider reorienting layout',
|
||||||
|
suggestedFix: 'Switch to horizontal layout or reorganize node positions'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect overlapping or too-close nodes
|
||||||
|
*/
|
||||||
|
private detectOverlappingNodes(): string[] {
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
const overlapping: string[] = [];
|
||||||
|
const threshold = 100; // Minimum distance between nodes
|
||||||
|
|
||||||
|
for (let i = 0; i < visibleNodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < visibleNodes.length; j++) {
|
||||||
|
const node1 = visibleNodes[i];
|
||||||
|
const node2 = visibleNodes[j];
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(node1.position.x - node2.position.x, 2) +
|
||||||
|
Math.pow(node1.position.y - node2.position.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance < threshold) {
|
||||||
|
overlapping.push(node1.id, node2.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...new Set(overlapping)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify layout strengths
|
||||||
|
*/
|
||||||
|
private identifyStrengths(): string[] {
|
||||||
|
const strengths: string[] = [];
|
||||||
|
const metrics = this.calculateMetrics();
|
||||||
|
|
||||||
|
if (metrics.edgeCrossings === 0) {
|
||||||
|
strengths.push('Clean layout with no edge crossings');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metrics.averageNodeSpacing > 120) {
|
||||||
|
strengths.push('Good spacing between nodes for readability');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metrics.visualComplexity < 30) {
|
||||||
|
strengths.push('Low visual complexity makes diagram easy to understand');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metrics.aspectRatio >= 0.8 && metrics.aspectRatio <= 1.5) {
|
||||||
|
strengths.push('Well-proportioned diagram layout');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeCount = this.nodes.filter(n => n.type !== 'group').length;
|
||||||
|
if (nodeCount <= 8) {
|
||||||
|
strengths.push('Appropriate number of nodes for clear communication');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strengths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate layout optimization suggestions
|
||||||
|
*/
|
||||||
|
generateSuggestions(): LayoutSuggestion[] {
|
||||||
|
const suggestions: LayoutSuggestion[] = [];
|
||||||
|
const { metrics, issues } = this.analyzeLayout();
|
||||||
|
|
||||||
|
// Suggest spacing improvements
|
||||||
|
if (metrics.averageNodeSpacing < 100) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'spacing-improvement',
|
||||||
|
title: 'Improve Node Spacing',
|
||||||
|
description: 'Increase spacing between nodes to improve readability and reduce visual clutter',
|
||||||
|
type: 'spacing',
|
||||||
|
impact: 'medium',
|
||||||
|
estimatedImprovement: 25,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: {
|
||||||
|
metrics: { ...metrics, averageNodeSpacing: 150, visualComplexity: Math.max(0, metrics.visualComplexity - 15) },
|
||||||
|
estimatedIssues: issues.filter(i => i.type !== 'poor-spacing')
|
||||||
|
},
|
||||||
|
implementation: {
|
||||||
|
nodePositions: this.calculateOptimalSpacing()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest routing optimization
|
||||||
|
if (metrics.edgeCrossings > 3) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'routing-optimization',
|
||||||
|
title: 'Optimize Edge Routing',
|
||||||
|
description: 'Reduce edge crossings by applying smart routing algorithms',
|
||||||
|
type: 'routing',
|
||||||
|
impact: 'high',
|
||||||
|
estimatedImprovement: 40,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: {
|
||||||
|
metrics: { ...metrics, edgeCrossings: Math.max(0, metrics.edgeCrossings - 5), visualComplexity: Math.max(0, metrics.visualComplexity - 20) },
|
||||||
|
estimatedIssues: issues.filter(i => i.type !== 'edge-crossing')
|
||||||
|
},
|
||||||
|
implementation: {
|
||||||
|
nodePositions: this.calculateRoutingOptimizedPositions(),
|
||||||
|
edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest grouping improvements
|
||||||
|
const similarNodes = this.findSimilarNodes();
|
||||||
|
if (similarNodes.length > 0) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'grouping-improvement',
|
||||||
|
title: 'Group Related Nodes',
|
||||||
|
description: 'Group similar nodes together to improve visual hierarchy and organization',
|
||||||
|
type: 'grouping',
|
||||||
|
impact: 'medium',
|
||||||
|
estimatedImprovement: 30,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: {
|
||||||
|
metrics: { ...metrics, visualComplexity: Math.max(0, metrics.visualComplexity - 10) },
|
||||||
|
estimatedIssues: issues.filter(i => i.type !== 'unclear-flow')
|
||||||
|
},
|
||||||
|
implementation: {
|
||||||
|
nodePositions: this.calculateGroupedPositions(similarNodes),
|
||||||
|
styleChanges: { groupNodes: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest Visual Hierarchy improvements
|
||||||
|
const centralNode = this.findCentralNode();
|
||||||
|
if (centralNode && (!centralNode.style?.width || (typeof centralNode.style.width === 'number' && centralNode.style.width < 100))) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'hierarchy-emphasis',
|
||||||
|
title: 'Emphasize Central Node',
|
||||||
|
description: `Node "${centralNode.data.label}" appears central to the graph. Consider increasing its size or prominence.`,
|
||||||
|
type: 'hierarchy',
|
||||||
|
impact: 'medium',
|
||||||
|
estimatedImprovement: 20,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: { metrics, estimatedIssues: [] },
|
||||||
|
implementation: {
|
||||||
|
nodePositions: {},
|
||||||
|
styleChanges: {
|
||||||
|
[centralNode.id]: {
|
||||||
|
width: 250,
|
||||||
|
height: 120,
|
||||||
|
style: { ...centralNode.style, width: 250, height: 120, fontSize: '1.2em', fontWeight: 'bold' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: `Make node ${centralNode.id} larger and more distinct.`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest Style Consistency
|
||||||
|
const styleIssues = this.identifyStyleIssues();
|
||||||
|
if (styleIssues.length > 0) {
|
||||||
|
suggestions.push({
|
||||||
|
id: 'style-consistency',
|
||||||
|
title: 'Fix Style Inconsistencies',
|
||||||
|
description: 'Detected nodes of the same type with different colors. Standardize them for consistency.',
|
||||||
|
type: 'style',
|
||||||
|
impact: 'low',
|
||||||
|
estimatedImprovement: 15,
|
||||||
|
beforeState: { metrics, issues: styleIssues },
|
||||||
|
afterState: { metrics, estimatedIssues: [] },
|
||||||
|
implementation: {
|
||||||
|
nodePositions: {},
|
||||||
|
styleChanges: this.generateStyleConsistencyChanges(styleIssues),
|
||||||
|
description: 'Apply consistent colors to node types.'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest Node View Optimization
|
||||||
|
const nodeOptimization = this.generateNodeViewSuggestions();
|
||||||
|
if (nodeOptimization) {
|
||||||
|
suggestions.push(nodeOptimization);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify style consistency issues
|
||||||
|
*/
|
||||||
|
private identifyStyleIssues(): VisualIssue[] {
|
||||||
|
const issues: VisualIssue[] = [];
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
const nodesByType = new Map<string, { colors: Set<string>, ids: string[] }>();
|
||||||
|
|
||||||
|
visibleNodes.forEach(node => {
|
||||||
|
const type = node.type || 'default';
|
||||||
|
const color = node.data?.color || node.style?.backgroundColor || 'default';
|
||||||
|
|
||||||
|
if (!nodesByType.has(type)) {
|
||||||
|
nodesByType.set(type, { colors: new Set(), ids: [] });
|
||||||
|
}
|
||||||
|
const entry = nodesByType.get(type)!;
|
||||||
|
entry.colors.add(color as string);
|
||||||
|
entry.ids.push(node.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesByType.forEach((entry, type) => {
|
||||||
|
if (entry.colors.size > 1) {
|
||||||
|
issues.push({
|
||||||
|
type: 'style-consistency',
|
||||||
|
severity: 'low',
|
||||||
|
description: `Nodes of type '${type}' have inconsistent colors`,
|
||||||
|
affectedNodes: entry.ids,
|
||||||
|
suggestedFix: 'Standardize color for this node type'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate style changes to fix consistency issues
|
||||||
|
*/
|
||||||
|
private generateStyleConsistencyChanges(issues: VisualIssue[]): Record<string, any> {
|
||||||
|
const changes: Record<string, any> = {};
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
database: '#0ea5e9', // Sky 500
|
||||||
|
service: '#8b5cf6', // Violet 500
|
||||||
|
client: '#f59e0b', // Amber 500
|
||||||
|
default: '#64748b' // Slate 500
|
||||||
|
};
|
||||||
|
|
||||||
|
issues.filter(i => i.type === 'style-consistency').forEach(issue => {
|
||||||
|
if (!issue.affectedNodes) return;
|
||||||
|
|
||||||
|
// Extract type from description or logic
|
||||||
|
let targetColor = typeColors.default;
|
||||||
|
if (issue.description.includes("'database'")) targetColor = typeColors.database;
|
||||||
|
if (issue.description.includes("'client'")) targetColor = typeColors.client;
|
||||||
|
if (issue.description.includes("'service'")) targetColor = typeColors.service;
|
||||||
|
|
||||||
|
issue.affectedNodes.forEach(nodeId => {
|
||||||
|
changes[nodeId] = {
|
||||||
|
color: targetColor,
|
||||||
|
style: { backgroundColor: targetColor }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the most central node (highest degree centrality)
|
||||||
|
*/
|
||||||
|
private findCentralNode(): Node | null {
|
||||||
|
if (this.nodes.length === 0) return null;
|
||||||
|
|
||||||
|
const degrees = new Map<string, number>();
|
||||||
|
this.edges.forEach(edge => {
|
||||||
|
degrees.set(edge.source, (degrees.get(edge.source) || 0) + 1);
|
||||||
|
degrees.set(edge.target, (degrees.get(edge.target) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
let maxDegree = -1;
|
||||||
|
let centralNodeId: string | null = null;
|
||||||
|
|
||||||
|
degrees.forEach((degree, id) => {
|
||||||
|
if (degree > maxDegree) {
|
||||||
|
maxDegree = degree;
|
||||||
|
centralNodeId = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (centralNodeId && maxDegree > 2) { // Threshold for "central"
|
||||||
|
return this.nodes.find(n => n.id === centralNodeId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate optimal spacing for nodes
|
||||||
|
*/
|
||||||
|
private calculateOptimalSpacing(): Record<string, NodePosition> {
|
||||||
|
const positions: Record<string, NodePosition> = {};
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
const gridSize = 200; // Optimal spacing
|
||||||
|
|
||||||
|
visibleNodes.forEach((node, index) => {
|
||||||
|
const row = Math.floor(index / Math.ceil(Math.sqrt(visibleNodes.length)));
|
||||||
|
const col = index % Math.ceil(Math.sqrt(visibleNodes.length));
|
||||||
|
|
||||||
|
positions[node.id] = {
|
||||||
|
x: col * gridSize + 100,
|
||||||
|
y: row * gridSize + 100
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate routing-optimized positions
|
||||||
|
*/
|
||||||
|
private calculateRoutingOptimizedPositions(): Record<string, NodePosition> {
|
||||||
|
// Use the existing layout engine with optimized settings
|
||||||
|
const { nodes: layoutedNodes } = getLayoutedElements(
|
||||||
|
this.nodes,
|
||||||
|
this.edges,
|
||||||
|
{ direction: 'TB', nodeSpacing: 80, rankSpacing: 100 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const positions: Record<string, NodePosition> = {};
|
||||||
|
layoutedNodes.forEach(node => {
|
||||||
|
positions[node.id] = { x: node.position.x, y: node.position.y };
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find similar nodes that could be grouped
|
||||||
|
*/
|
||||||
|
private findSimilarNodes(): Array<{ nodeIds: string[]; similarity: string }> {
|
||||||
|
const similar: Array<{ nodeIds: string[]; similarity: string }> = [];
|
||||||
|
const visibleNodes = this.nodes.filter(n => n.type !== 'group');
|
||||||
|
|
||||||
|
// Group by node type
|
||||||
|
const nodesByType = new Map<string, Node[]>();
|
||||||
|
visibleNodes.forEach(node => {
|
||||||
|
const type = node.type || 'default';
|
||||||
|
if (!nodesByType.has(type)) {
|
||||||
|
nodesByType.set(type, []);
|
||||||
|
}
|
||||||
|
nodesByType.get(type)!.push(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodesByType.forEach((nodes, type) => {
|
||||||
|
if (nodes.length > 1) {
|
||||||
|
similar.push({
|
||||||
|
nodeIds: nodes.map(n => n.id),
|
||||||
|
similarity: `Same type: ${type}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return similar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate grouped positions for similar nodes
|
||||||
|
*/
|
||||||
|
private calculateGroupedPositions(similarNodes: Array<{ nodeIds: string[]; similarity: string }>): Record<string, NodePosition> {
|
||||||
|
const positions: Record<string, NodePosition> = {};
|
||||||
|
const groupSize = 150;
|
||||||
|
let groupIndex = 0;
|
||||||
|
|
||||||
|
similarNodes.forEach(group => {
|
||||||
|
group.nodeIds.forEach((nodeId, index) => {
|
||||||
|
positions[nodeId] = {
|
||||||
|
x: groupIndex * 300 + (index % 2) * groupSize,
|
||||||
|
y: groupIndex * 200 + Math.floor(index / 2) * groupSize
|
||||||
|
};
|
||||||
|
});
|
||||||
|
groupIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a layout suggestion
|
||||||
|
*/
|
||||||
|
applySuggestion(suggestion: LayoutSuggestion): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
const newNodes = this.nodes.map(node => {
|
||||||
|
let updatedNode = { ...node };
|
||||||
|
|
||||||
|
// Apply position changes
|
||||||
|
if (suggestion.implementation.nodePositions[node.id]) {
|
||||||
|
const newPos = suggestion.implementation.nodePositions[node.id];
|
||||||
|
updatedNode.position = { x: newPos.x, y: newPos.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply style changes
|
||||||
|
if (suggestion.implementation.styleChanges && suggestion.implementation.styleChanges[node.id]) {
|
||||||
|
const styleUpdates = suggestion.implementation.styleChanges[node.id];
|
||||||
|
updatedNode.style = { ...updatedNode.style, ...styleUpdates };
|
||||||
|
updatedNode.data = { ...updatedNode.data, ...styleUpdates }; // Also update data for some properties if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply edge changes if any
|
||||||
|
let newEdges = this.edges;
|
||||||
|
if (suggestion.implementation.edgeRouting) {
|
||||||
|
// Logic to update edge styles if needed
|
||||||
|
newEdges = this.edges.map(edge => ({
|
||||||
|
...edge,
|
||||||
|
type: suggestion.implementation.edgeRouting?.style === 'curved' ? 'curved' : 'straight',
|
||||||
|
data: { ...edge.data, offset: suggestion.implementation.edgeRouting?.offsetStrategy === 'intelligent' ? 20 : 0 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: newNodes, edges: newEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate suggestions for optimizing node views based on semantics
|
||||||
|
*/
|
||||||
|
private generateNodeViewSuggestions(): LayoutSuggestion | null {
|
||||||
|
const styleChanges: Record<string, any> = {};
|
||||||
|
const { metrics, issues } = this.analyzeLayout();
|
||||||
|
let improvementCount = 0;
|
||||||
|
|
||||||
|
this.nodes.forEach(node => {
|
||||||
|
if (node.type === 'group') return;
|
||||||
|
|
||||||
|
const semantics = this.analyzeNodeSemantics(node);
|
||||||
|
if (!semantics) return;
|
||||||
|
|
||||||
|
// Define semantic styles
|
||||||
|
const styles = {
|
||||||
|
decision: { shape: 'diamond', backgroundColor: '#fcd34d', borderColor: '#d97706', borderRadius: '4px' }, // Amber
|
||||||
|
action: { shape: 'rect', backgroundColor: '#a78bfa', borderColor: '#7c3aed', borderRadius: '8px' }, // Violet
|
||||||
|
data: { shape: 'cylinder', backgroundColor: '#38bdf8', borderColor: '#0284c7', borderRadius: '12px' }, // Sky
|
||||||
|
startEnd: { shape: 'pill', backgroundColor: '#4ade80', borderColor: '#16a34a', borderRadius: '999px' }, // Green
|
||||||
|
concept: { shape: 'rect', backgroundColor: '#e2e8f0', borderColor: '#64748b', borderRadius: '6px' } // Slate
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if current style matches semantics (simplified check)
|
||||||
|
// In a real app we would compare actual style properties
|
||||||
|
// Here we assume if it's "default" or distinct enough we suggest it
|
||||||
|
|
||||||
|
if (semantics === 'decision' && !node.data.label?.includes('?')) return; // Extra check for decisions
|
||||||
|
|
||||||
|
const suggestedStyle = styles[semantics];
|
||||||
|
styleChanges[node.id] = {
|
||||||
|
style: {
|
||||||
|
backgroundColor: suggestedStyle.backgroundColor,
|
||||||
|
border: `1px solid ${suggestedStyle.borderColor}`,
|
||||||
|
borderRadius: suggestedStyle.borderRadius
|
||||||
|
}
|
||||||
|
};
|
||||||
|
improvementCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (improvementCount > 0) {
|
||||||
|
return {
|
||||||
|
id: 'node-optimization',
|
||||||
|
title: 'Optimize Node Views',
|
||||||
|
description: `Found ${improvementCount} nodes where visual style can better match their semantic meaning (Decisions, Actions, Data).`,
|
||||||
|
type: 'node-color-semantic',
|
||||||
|
impact: 'medium',
|
||||||
|
estimatedImprovement: 25,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: { metrics, estimatedIssues: [] },
|
||||||
|
implementation: {
|
||||||
|
nodePositions: {},
|
||||||
|
styleChanges,
|
||||||
|
description: 'Apply semantic styling to nodes based on their content.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze node label to guess its semantic role
|
||||||
|
*/
|
||||||
|
private analyzeNodeSemantics(node: Node): 'decision' | 'action' | 'data' | 'startEnd' | 'concept' | null {
|
||||||
|
const label = node.data.label?.toLowerCase() || '';
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
// Decision patterns
|
||||||
|
if (label.includes('?') || label.startsWith('is ') || label.startsWith('check ') || label.includes('approve') || label.includes('review')) {
|
||||||
|
return 'decision';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data patterns
|
||||||
|
if (label.includes('data') || label.includes('database') || label.includes('store') || label.includes('json') || label.includes('record')) {
|
||||||
|
return 'data';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start/End patterns
|
||||||
|
if (label === 'start' || label === 'end' || label === 'begin' || label === 'stop' || label === 'finish') {
|
||||||
|
return 'startEnd';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action patterns (verbs suitable for processes)
|
||||||
|
const actionVerbs = ['create', 'update', 'delete', 'process', 'calculate', 'send', 'receive', 'generate', 'publish', 'edit'];
|
||||||
|
if (actionVerbs.some(v => label.includes(v))) {
|
||||||
|
return 'action';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'concept'; // Default fallback for content-heavy nodes
|
||||||
|
}
|
||||||
|
getPresets(): LayoutSuggestion[] {
|
||||||
|
const { metrics, issues } = this.analyzeLayout();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'preset-compact',
|
||||||
|
title: 'Compact Grid',
|
||||||
|
description: 'Arranges nodes in a tight grid to save space',
|
||||||
|
type: 'grouping',
|
||||||
|
impact: 'high',
|
||||||
|
estimatedImprovement: 50,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: { metrics, estimatedIssues: [] },
|
||||||
|
implementation: {
|
||||||
|
nodePositions: this.calculateOptimalSpacing()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'preset-flow',
|
||||||
|
title: 'Clear Flow',
|
||||||
|
description: 'Optimizes for top-to-bottom flow with minimal crossings',
|
||||||
|
type: 'routing',
|
||||||
|
impact: 'high',
|
||||||
|
estimatedImprovement: 40,
|
||||||
|
beforeState: { metrics, issues },
|
||||||
|
afterState: { metrics, estimatedIssues: [] },
|
||||||
|
implementation: {
|
||||||
|
nodePositions: this.calculateRoutingOptimizedPositions(),
|
||||||
|
edgeRouting: { style: 'curved', offsetStrategy: 'intelligent' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to create visual organizer instance
|
||||||
|
*/
|
||||||
|
export function createVisualOrganizer(nodes: Node[], edges: Edge[]): VisualOrganizer {
|
||||||
|
return new VisualOrganizer(nodes, edges);
|
||||||
|
}
|
||||||
103
src/lib/webLlmService.ts
Normal file
103
src/lib/webLlmService.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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> {
|
||||||
|
if (!navigator.gpu) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
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();
|
||||||
29
src/main.tsx
Normal file
29
src/main.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
// Error boundary for catching any initialization errors
|
||||||
|
try {
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize app:', error);
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (rootElement) {
|
||||||
|
rootElement.innerHTML = `
|
||||||
|
<div style="padding: 20px; color: red; font-family: monospace;">
|
||||||
|
<h1>App Failed to Load</h1>
|
||||||
|
<pre>${error instanceof Error ? error.message : 'Unknown error'}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/pages/Dashboard.tsx
Normal file
175
src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { SettingsModal } from '../components/Settings';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
savedDiagrams, theme, toggleTheme
|
||||||
|
} = useFlowStore();
|
||||||
|
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setUploadProgress(prev => {
|
||||||
|
if (prev >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(() => navigate('/diagram'), 800);
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return prev + Math.random() * 30;
|
||||||
|
});
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80vw] h-[80vh] bg-blue-500/5 blur-[160px] rounded-full dark:bg-blue-500/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Navigation */}
|
||||||
|
<header className="absolute top-0 left-0 right-0 h-20 px-12 flex items-center justify-between z-50">
|
||||||
|
<div className="flex items-center gap-4 group cursor-pointer" onClick={() => window.location.reload()}>
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-blue-600 flex items-center justify-center shadow-lg group-hover:scale-110 transition-all duration-500">
|
||||||
|
<Zap className="w-4 h-4 text-white fill-white/20" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-display font-black tracking-tight">SystemArchitect</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-surface border titanium-border text-secondary hover:text-primary transition-all"
|
||||||
|
>
|
||||||
|
{theme === 'light' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className={`btn-ghost ${showSettings ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Floating Settings Panel */}
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="h-full flex flex-col items-center justify-center relative z-10 px-12">
|
||||||
|
<div className="max-w-4xl w-full text-center mb-16 animate-slide-up">
|
||||||
|
<h1 className="text-7xl font-display font-black tracking-tighter leading-[0.9] mb-6 text-primary">
|
||||||
|
Design the <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-indigo-500">Unseen logic</span>.
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-secondary max-w-lg mx-auto leading-relaxed mb-12 italic">
|
||||||
|
Transform complex architectures into living, collaborative diagrams with the help of local neural vision.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4 w-full max-w-2xl">
|
||||||
|
<button
|
||||||
|
className="btn-primary flex-1 min-w-[240px] group h-14"
|
||||||
|
onClick={() => !isUploading && document.getElementById('main-upload')?.click()}
|
||||||
|
>
|
||||||
|
{!isUploading ? (
|
||||||
|
<>
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
Scan Architecture
|
||||||
|
<ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Activity className="w-4 h-4 animate-pulse" />
|
||||||
|
Processing: {Math.round(uploadProgress)}%
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-secondary flex-1 min-w-[240px] h-14 group"
|
||||||
|
onClick={() => navigate('/diagram')}
|
||||||
|
>
|
||||||
|
<Eye className="w-5 h-5 text-blue-500" />
|
||||||
|
Direct Access
|
||||||
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform opacity-40" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input id="main-upload" type="file" className="hidden" onChange={handleFileSelect} />
|
||||||
|
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-tertiary">
|
||||||
|
Drop files anywhere or click to start
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compact Recent Intelligence */}
|
||||||
|
<div className="w-full max-w-5xl grid grid-cols-1 sm:grid-cols-3 gap-6 animate-slide-up" style={{ animationDelay: '0.2s' }}>
|
||||||
|
{savedDiagrams.length > 0 ? (
|
||||||
|
[...savedDiagrams].reverse().slice(0, 3).map((diagram) => (
|
||||||
|
<div
|
||||||
|
key={diagram.id}
|
||||||
|
className="glass-panel rounded-2xl p-6 flex items-center justify-between group cursor-pointer hover:border-blue-500/30 shadow-sm hover:shadow-xl transition-all"
|
||||||
|
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 overflow-hidden">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
|
||||||
|
<Activity className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-left">
|
||||||
|
<h4 className="font-bold text-sm truncate text-primary">{diagram.name}</h4>
|
||||||
|
<p className="text-[9px] font-black text-tertiary uppercase tracking-widest">{diagram.nodes.filter(n => n.type !== 'group').length} Entities</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-4 h-4 text-tertiary group-hover:text-primary transition-all opacity-0 group-hover:opacity-100 scale-0 group-hover:scale-100" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="glass-panel border-dashed rounded-2xl p-6 opacity-20" />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{savedDiagrams.length > 3 && (
|
||||||
|
<div className="mt-8 animate-slide-up" style={{ animationDelay: '0.3s' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/history')}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-surface border titanium-border text-[10px] font-black uppercase tracking-widest text-tertiary hover:text-blue-500 hover:border-blue-500/30 transition-all hover:scale-105"
|
||||||
|
>
|
||||||
|
<span>Search Intelligence Archive</span>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
<footer className="absolute bottom-10 left-12 flex items-center gap-10 text-[9px] font-black tracking-[0.3em] text-tertiary uppercase">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-px bg-slate-500/20" />
|
||||||
|
Neural Engine v3.1.2
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/pages/Editor.tsx
Normal file
179
src/pages/Editor.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react';
|
||||||
|
import { InputPanel } from '../components/InputPanel';
|
||||||
|
import { FlowCanvas } from '../components/FlowCanvas';
|
||||||
|
import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
|
||||||
|
import InteractiveLegend from '../components/InteractiveLegend';
|
||||||
|
import OnboardingTour from '../components/OnboardingTour';
|
||||||
|
import { useFlowStore } from '../store';
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { PanelLeft, PanelRight, Zap, Sparkles, Minimize2 } from 'lucide-react';
|
||||||
|
import { OrchestratorLoader } from '../components/ui/OrchestratorLoader';
|
||||||
|
import { EditorHeader } from '../components/editor/EditorHeader';
|
||||||
|
|
||||||
|
export function Editor() {
|
||||||
|
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode } = useFlowStore();
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
|
||||||
|
const isResizing = useRef(false);
|
||||||
|
|
||||||
|
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||||
|
isResizing.current = true;
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', stopResizing);
|
||||||
|
document.body.style.cursor = 'col-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stopResizing = useCallback(() => {
|
||||||
|
isResizing.current = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', stopResizing);
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
document.body.style.userSelect = 'auto';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||||
|
if (!isResizing.current) return;
|
||||||
|
const newWidth = Math.min(Math.max(e.clientX, 300), 700);
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup listeners on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', stopResizing);
|
||||||
|
};
|
||||||
|
}, [handleMouseMove, stopResizing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<div className="h-screen w-screen flex flex-col bg-void text-primary overflow-hidden font-sans relative">
|
||||||
|
{!focusMode && <EditorHeader />}
|
||||||
|
|
||||||
|
{/* Exit Focus Mode Trigger */}
|
||||||
|
{focusMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusMode(false)}
|
||||||
|
className="absolute top-6 right-6 z-[100] w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-blue-500 hover:scale-110 transition-all shadow-2xl animate-fade-in border-blue-500/20"
|
||||||
|
title="Exit Focus Mode"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden relative z-10">
|
||||||
|
{/* Left Resizable Panel */}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)] border-r border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl relative flex flex-col ${(leftPanelOpen && !focusMode) ? 'opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`}
|
||||||
|
style={{ width: (leftPanelOpen && !focusMode) ? sidebarWidth : 0 }}
|
||||||
|
>
|
||||||
|
{/* System Architect Header */}
|
||||||
|
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-lg bg-blue-500/10">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500 dark:text-tertiary">System Architect</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftPanelOpen(false)}
|
||||||
|
className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<PanelLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col relative overflow-hidden" style={{ width: sidebarWidth }}>
|
||||||
|
<div className="flex-1 overflow-y-auto hide-scrollbar">
|
||||||
|
<InputPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
{leftPanelOpen && (
|
||||||
|
<div
|
||||||
|
onMouseDown={startResizing}
|
||||||
|
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-blue-500/30 transition-colors z-[100] group"
|
||||||
|
>
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-0.5 h-8 bg-slate-200 dark:bg-slate-500/20 group-hover:bg-blue-500 rounded-full transition-colors" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!leftPanelOpen && !focusMode) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftPanelOpen(true)}
|
||||||
|
className="absolute left-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-slate-500 dark:text-secondary hover:text-blue-500 hover:scale-110 transition-all shadow-xl bg-white/80 dark:bg-slate-900/50 border border-black/5 dark:border-white/10"
|
||||||
|
>
|
||||||
|
<PanelLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panoramic Canvas */}
|
||||||
|
<main className="flex-1 relative overflow-hidden">
|
||||||
|
<FlowCanvas />
|
||||||
|
|
||||||
|
<InteractiveLegend />
|
||||||
|
<OnboardingTour />
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/60 dark:bg-slate-950/60 backdrop-blur-xl animate-fade-in">
|
||||||
|
<OrchestratorLoader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty Workspace */}
|
||||||
|
{nodes.length === 0 && !isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
|
||||||
|
<div className="text-center p-12 floating-glass rounded-[2.5rem] max-w-sm pointer-events-auto shadow-2xl border border-black/5 dark:border-white/10 bg-white/50 dark:bg-black/20 backdrop-blur-xl">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-6 shadow-xl shadow-blue-600/20">
|
||||||
|
<Zap className="w-6 h-6 text-white fill-white/20" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-display font-black tracking-tight text-slate-800 dark:text-primary mb-2">Void Canvas Ready</h2>
|
||||||
|
<p className="text-slate-500 dark:text-secondary text-[11px] leading-relaxed mb-6 italic">
|
||||||
|
Initialize the interface via the terminal or drop a blueprint to begin neural synthesis.
|
||||||
|
</p>
|
||||||
|
{!leftPanelOpen && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLeftPanelOpen(true)}
|
||||||
|
className="btn-primary w-full py-2.5"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
Engage Interface
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Right Inspector Panel - Flex layout instead of absolute */}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-500 ease-out border-l border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl flex flex-col ${(rightPanelOpen && !focusMode) ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`}
|
||||||
|
>
|
||||||
|
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.3em] text-slate-500 dark:text-tertiary">Inspector</span>
|
||||||
|
<button onClick={() => setRightPanelOpen(false)} className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors">
|
||||||
|
<PanelRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto hide-scrollbar">
|
||||||
|
<NodeDetailsPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(nodes.length > 0 && !rightPanelOpen && !focusMode) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setRightPanelOpen(true)}
|
||||||
|
className="absolute right-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-secondary hover:text-indigo-500 hover:scale-110 transition-all shadow-xl"
|
||||||
|
>
|
||||||
|
<PanelRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/pages/History.tsx
Normal file
132
src/pages/History.tsx
Normal file
|
|
@ -0,0 +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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/store/diagramStore.ts
Normal file
187
src/store/diagramStore.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* Diagram Store - Manages nodes, edges, and diagram operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
|
||||||
|
import type { NodeChange, EdgeChange } from '@xyflow/react';
|
||||||
|
import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types';
|
||||||
|
|
||||||
|
interface DiagramState {
|
||||||
|
// Diagram data
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
sourceCode: string;
|
||||||
|
edgeStyle: EdgeStyle;
|
||||||
|
savedDiagrams: SavedDiagram[];
|
||||||
|
generationComplexity: 'simple' | 'complex';
|
||||||
|
layout: string;
|
||||||
|
analysisResult: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setNodes: (nodes: Node[]) => void;
|
||||||
|
setEdges: (edges: Edge[]) => void;
|
||||||
|
setSourceCode: (code: string) => void;
|
||||||
|
setEdgeStyle: (style: EdgeStyle) => void;
|
||||||
|
setGenerationComplexity: (complexity: 'simple' | 'complex') => void;
|
||||||
|
|
||||||
|
// React Flow handlers
|
||||||
|
onNodesChange: (changes: NodeChange[]) => void;
|
||||||
|
onEdgesChange: (changes: EdgeChange[]) => void;
|
||||||
|
onConnect: (connection: Connection) => void;
|
||||||
|
|
||||||
|
// Node operations
|
||||||
|
updateNodeData: (nodeId: string, data: Partial<Node['data']>) => void;
|
||||||
|
updateNodeType: (nodeId: string, type: string) => void;
|
||||||
|
deleteNode: (nodeId: string) => void;
|
||||||
|
|
||||||
|
// Diagram persistence
|
||||||
|
saveDiagram: (name: string) => void;
|
||||||
|
loadDiagram: (id: string) => void;
|
||||||
|
deleteDiagram: (id: string) => void;
|
||||||
|
getSavedDiagrams: () => SavedDiagram[];
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
nodes: [] as Node[],
|
||||||
|
edges: [] as Edge[],
|
||||||
|
sourceCode: '',
|
||||||
|
edgeStyle: 'curved' as EdgeStyle,
|
||||||
|
layout: 'dagre',
|
||||||
|
analysisResult: null as string | null,
|
||||||
|
savedDiagrams: JSON.parse(localStorage.getItem('flowgen_diagrams') || '[]') as SavedDiagram[],
|
||||||
|
generationComplexity: 'simple' as 'simple' | 'complex',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDiagramStore = create<DiagramState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// Setters
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
setSourceCode: (sourceCode) => set({ sourceCode }),
|
||||||
|
setEdgeStyle: (edgeStyle) => set({ edgeStyle }),
|
||||||
|
setGenerationComplexity: (generationComplexity) => set({ generationComplexity }),
|
||||||
|
|
||||||
|
// React Flow handlers
|
||||||
|
onNodesChange: (changes) => {
|
||||||
|
set({
|
||||||
|
nodes: applyNodeChanges(changes, get().nodes),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onEdgesChange: (changes) => {
|
||||||
|
set({
|
||||||
|
edges: applyEdgeChanges(changes, get().edges),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onConnect: (connection) => {
|
||||||
|
set({
|
||||||
|
edges: addEdge(connection, get().edges),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Node operations
|
||||||
|
updateNodeData: (nodeId, data) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) =>
|
||||||
|
node.id === nodeId
|
||||||
|
? { ...node, data: { ...node.data, ...data } }
|
||||||
|
: node
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNodeType: (nodeId, type) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map((node) =>
|
||||||
|
node.id === nodeId ? { ...node, type } : node
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNode: (nodeId) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.filter((node) => node.id !== nodeId),
|
||||||
|
edges: get().edges.filter(
|
||||||
|
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Diagram persistence
|
||||||
|
saveDiagram: (name) => {
|
||||||
|
const { nodes, edges, sourceCode } = get();
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newDiagram: SavedDiagram = {
|
||||||
|
id: `diagram_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
sourceCode,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
'flowgen_diagrams',
|
||||||
|
JSON.stringify([newDiagram, ...diagrams])
|
||||||
|
);
|
||||||
|
set({ savedDiagrams: [newDiagram, ...diagrams] });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDiagram: (id) => {
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
const diagram = diagrams.find((d) => d.id === id);
|
||||||
|
|
||||||
|
if (diagram) {
|
||||||
|
set({
|
||||||
|
nodes: diagram.nodes,
|
||||||
|
edges: diagram.edges,
|
||||||
|
sourceCode: diagram.sourceCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDiagram: (id) => {
|
||||||
|
const diagrams = get().getSavedDiagrams();
|
||||||
|
localStorage.setItem(
|
||||||
|
'flowgen_diagrams',
|
||||||
|
JSON.stringify(diagrams.filter((d) => d.id !== id))
|
||||||
|
);
|
||||||
|
set({ savedDiagrams: diagrams.filter((d) => d.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
getSavedDiagrams: () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('flowgen_diagrams');
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'flowgen-diagram-storage',
|
||||||
|
// Only persist saved diagrams and settings, NOT the current diagram
|
||||||
|
// This ensures canvas clears on refresh unless user saves a draft
|
||||||
|
partialize: (state) => ({
|
||||||
|
savedDiagrams: state.savedDiagrams,
|
||||||
|
edgeStyle: state.edgeStyle,
|
||||||
|
generationComplexity: state.generationComplexity,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
299
src/store/flowStore.ts
Normal file
299
src/store/flowStore.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
/**
|
||||||
|
* @deprecated THIS FILE IS DEPRECATED AND SHOULD NOT BE USED.
|
||||||
|
* All components have been migrated to use the centralized store in '../store/index.ts'.
|
||||||
|
* This file is kept only to prevent build errors if any obscure imports remain, but should be deleted soon.
|
||||||
|
*
|
||||||
|
* Legacy Flow Store - Maintained for backward compatibility
|
||||||
|
*
|
||||||
|
* @deprecated Use the individual stores instead:
|
||||||
|
* - useDiagramStore - for nodes, edges, diagram operations
|
||||||
|
* - useSettingsStore - for AI configuration and theme
|
||||||
|
* - useUIStore - for UI state and interactions
|
||||||
|
*
|
||||||
|
* Or use the combined useFlowStore from './index.ts'
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
addEdge,
|
||||||
|
applyNodeChanges,
|
||||||
|
applyEdgeChanges,
|
||||||
|
type Node as FlowNode,
|
||||||
|
type Edge as FlowEdge,
|
||||||
|
type Connection,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
|
||||||
|
// Enhanced Node type with metadata
|
||||||
|
export interface NodeMetadata {
|
||||||
|
techStack: string[];
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Node = FlowNode<{
|
||||||
|
label?: string;
|
||||||
|
metadata?: NodeMetadata;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type Edge = FlowEdge;
|
||||||
|
|
||||||
|
type OnNodesChange = Parameters<typeof applyNodeChanges>[0];
|
||||||
|
type OnEdgesChange = Parameters<typeof applyEdgeChanges>[0];
|
||||||
|
|
||||||
|
interface SavedDiagram {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
sourceCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowState {
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
selectedNode: Node | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
sourceCode: string;
|
||||||
|
apiKey: string;
|
||||||
|
modelName: string;
|
||||||
|
ollamaUrl: string;
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
leftPanelOpen: boolean;
|
||||||
|
rightPanelOpen: boolean;
|
||||||
|
focusMode: boolean;
|
||||||
|
activeFilters: string[];
|
||||||
|
aiMode: 'online' | 'offline';
|
||||||
|
onlineProvider: 'openai' | 'gemini' | 'ollama-cloud';
|
||||||
|
edgeStyle: 'curved' | 'straight';
|
||||||
|
|
||||||
|
// Computed property for saved diagrams
|
||||||
|
savedDiagrams: SavedDiagram[];
|
||||||
|
|
||||||
|
setNodes: (nodes: Node[]) => void;
|
||||||
|
setEdges: (edges: Edge[]) => void;
|
||||||
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
|
||||||
|
setLeftPanelOpen: (leftPanelOpen: boolean) => void;
|
||||||
|
setRightPanelOpen: (rightPanelOpen: boolean) => void;
|
||||||
|
|
||||||
|
toggleTheme: () => void;
|
||||||
|
|
||||||
|
setFocusMode: (focusMode: boolean) => void;
|
||||||
|
|
||||||
|
toggleFilter: (filterId: string) => void;
|
||||||
|
|
||||||
|
onNodesChange: (changes: OnNodesChange) => void;
|
||||||
|
onEdgesChange: (changes: OnEdgesChange) => void;
|
||||||
|
onConnect: (connection: Connection) => void;
|
||||||
|
|
||||||
|
setLoading: (isLoading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
setSourceCode: (sourceCode: string) => void;
|
||||||
|
|
||||||
|
setApiKey: (apiKey: string) => void;
|
||||||
|
setModelName: (modelName: string) => void;
|
||||||
|
setOllamaUrl: (ollamaUrl: string) => void;
|
||||||
|
setAiMode: (aiMode: 'online' | 'offline') => void;
|
||||||
|
setOnlineProvider: (onlineProvider: 'openai' | 'gemini' | 'ollama-cloud') => void;
|
||||||
|
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
// Node editing
|
||||||
|
updateNodeData: (nodeId: string, data: Partial<Node['data']>) => void;
|
||||||
|
updateNodeType: (nodeId: string, type: string) => void;
|
||||||
|
deleteNode: (nodeId: string) => void;
|
||||||
|
saveDiagram: (name: string) => void;
|
||||||
|
loadDiagram: (id: string) => void;
|
||||||
|
deleteDiagram: (id: string) => void;
|
||||||
|
setEdgeStyle: (style: 'curved' | 'straight') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialNodes: Node[] = [];
|
||||||
|
const initialEdges: Edge[] = [];
|
||||||
|
|
||||||
|
export const useFlowStore = create<FlowState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
nodes: initialNodes,
|
||||||
|
edges: initialEdges,
|
||||||
|
selectedNode: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
sourceCode: '',
|
||||||
|
apiKey: localStorage.getItem('flowgen_api_key') || '',
|
||||||
|
modelName: localStorage.getItem('flowgen_model_name') || 'llama3.2-vision',
|
||||||
|
ollamaUrl: localStorage.getItem('flowgen_ollama_url') || 'http://localhost:11434',
|
||||||
|
theme: (localStorage.getItem('flowgen_theme') as 'dark' | 'light') || 'dark',
|
||||||
|
leftPanelOpen: true,
|
||||||
|
rightPanelOpen: false,
|
||||||
|
focusMode: false,
|
||||||
|
edgeStyle: 'curved',
|
||||||
|
activeFilters: ['filter-client', 'filter-server', 'filter-db', 'filter-other'],
|
||||||
|
aiMode: (localStorage.getItem('flowgen_ai_mode') as 'online' | 'offline') || 'offline',
|
||||||
|
onlineProvider: (localStorage.getItem('flowgen_online_provider') as 'openai' | 'gemini' | 'ollama-cloud') || 'openai',
|
||||||
|
|
||||||
|
// Return saved diagrams from localStorage
|
||||||
|
savedDiagrams: JSON.parse(localStorage.getItem('flowgen_diagrams') || '[]') as SavedDiagram[],
|
||||||
|
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
setSelectedNode: (node) => set({ selectedNode: node, rightPanelOpen: node !== null }),
|
||||||
|
|
||||||
|
setLeftPanelOpen: (leftPanelOpen) => set({ leftPanelOpen }),
|
||||||
|
setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }),
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const newTheme = get().theme === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark');
|
||||||
|
localStorage.setItem('flowgen_theme', newTheme);
|
||||||
|
set({ theme: newTheme });
|
||||||
|
},
|
||||||
|
|
||||||
|
setFocusMode: (focusMode) => set({ focusMode }),
|
||||||
|
|
||||||
|
toggleFilter: (filterId) => {
|
||||||
|
const activeFilters = get().activeFilters;
|
||||||
|
const isActive = activeFilters.includes(filterId);
|
||||||
|
set({
|
||||||
|
activeFilters: isActive ? activeFilters.filter(f => f !== filterId) : [...activeFilters, filterId]
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onNodesChange: (changes) => {
|
||||||
|
set({
|
||||||
|
nodes: applyNodeChanges(changes, get().nodes),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onEdgesChange: (changes) => {
|
||||||
|
set({
|
||||||
|
edges: applyEdgeChanges(changes, get().edges),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onConnect: (connection) => {
|
||||||
|
set({
|
||||||
|
edges: addEdge(connection, get().edges),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
setSourceCode: (sourceCode) => set({ sourceCode }),
|
||||||
|
|
||||||
|
setApiKey: (apiKey) => {
|
||||||
|
localStorage.setItem('flowgen_api_key', apiKey);
|
||||||
|
set({ apiKey });
|
||||||
|
},
|
||||||
|
|
||||||
|
setModelName: (modelName) => {
|
||||||
|
localStorage.setItem('flowgen_model_name', modelName);
|
||||||
|
set({ modelName });
|
||||||
|
},
|
||||||
|
|
||||||
|
setOllamaUrl: (ollamaUrl) => {
|
||||||
|
localStorage.setItem('flowgen_ollama_url', ollamaUrl);
|
||||||
|
set({ ollamaUrl });
|
||||||
|
},
|
||||||
|
|
||||||
|
setAiMode: (aiMode) => {
|
||||||
|
localStorage.setItem('flowgen_ai_mode', aiMode);
|
||||||
|
set({ aiMode });
|
||||||
|
},
|
||||||
|
|
||||||
|
setOnlineProvider: (onlineProvider) => {
|
||||||
|
localStorage.setItem('flowgen_online_provider', onlineProvider);
|
||||||
|
set({ onlineProvider });
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => set({
|
||||||
|
nodes: initialNodes,
|
||||||
|
edges: initialEdges,
|
||||||
|
selectedNode: null,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Node editing
|
||||||
|
updateNodeData: (nodeId, data) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map(node =>
|
||||||
|
node.id === nodeId
|
||||||
|
? { ...node, data: { ...node.data, ...data } }
|
||||||
|
: node
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNodeType: (nodeId, type) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.map(node =>
|
||||||
|
node.id === nodeId ? { ...node, type } : node
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteNode: (nodeId) => {
|
||||||
|
set({
|
||||||
|
nodes: get().nodes.filter(node => node.id !== nodeId),
|
||||||
|
edges: get().edges.filter(edge =>
|
||||||
|
edge.source !== nodeId && edge.target !== nodeId
|
||||||
|
),
|
||||||
|
selectedNode: null,
|
||||||
|
rightPanelOpen: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Diagram persistence
|
||||||
|
saveDiagram: (name) => {
|
||||||
|
const { nodes, edges, sourceCode } = get();
|
||||||
|
const diagrams = JSON.parse(localStorage.getItem('flowgen_diagrams') || '[]');
|
||||||
|
const newDiagram: SavedDiagram = {
|
||||||
|
id: `diagram_${Date.now()}`,
|
||||||
|
name,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
sourceCode,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const updatedDiagrams = [newDiagram, ...diagrams];
|
||||||
|
localStorage.setItem('flowgen_diagrams', JSON.stringify(updatedDiagrams));
|
||||||
|
set({ savedDiagrams: updatedDiagrams });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadDiagram: (id) => {
|
||||||
|
const diagrams = JSON.parse(localStorage.getItem('flowgen_diagrams') || '[]');
|
||||||
|
const diagram = diagrams.find((d: { id: string }) => d.id === id);
|
||||||
|
if (diagram) {
|
||||||
|
set({
|
||||||
|
nodes: diagram.nodes,
|
||||||
|
edges: diagram.edges,
|
||||||
|
sourceCode: diagram.sourceCode || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteDiagram: (id) => {
|
||||||
|
const diagrams = JSON.parse(localStorage.getItem('flowgen_diagrams') || '[]');
|
||||||
|
const updatedDiagrams = diagrams.filter((d: { id: string }) => d.id !== id);
|
||||||
|
localStorage.setItem('flowgen_diagrams', JSON.stringify(updatedDiagrams));
|
||||||
|
set({ savedDiagrams: updatedDiagrams });
|
||||||
|
},
|
||||||
|
|
||||||
|
setEdgeStyle: (edgeStyle) => set({ edgeStyle }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'flowgen-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
nodes: state.nodes,
|
||||||
|
edges: state.edges,
|
||||||
|
sourceCode: state.sourceCode,
|
||||||
|
theme: state.theme,
|
||||||
|
edgeStyle: state.edgeStyle,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
98
src/store/index.ts
Normal file
98
src/store/index.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Store exports - Centralized store access
|
||||||
|
*
|
||||||
|
* The store is split into three slices for better separation of concerns:
|
||||||
|
* - diagramStore: Manages nodes, edges, and diagram operations
|
||||||
|
* - settingsStore: Manages AI configuration and app settings
|
||||||
|
* - uiStore: Manages UI state and interactions
|
||||||
|
*
|
||||||
|
* For backward compatibility, we also export a combined hook.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useDiagramStore } from './diagramStore';
|
||||||
|
export { useSettingsStore } from './settingsStore';
|
||||||
|
export { useUIStore } from './uiStore';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type { Node, Edge } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined store hook for backward compatibility
|
||||||
|
* Use individual stores when possible for better performance
|
||||||
|
*/
|
||||||
|
import { useDiagramStore } from './diagramStore';
|
||||||
|
import { useSettingsStore } from './settingsStore';
|
||||||
|
import { useUIStore } from './uiStore';
|
||||||
|
|
||||||
|
export function useFlowStore() {
|
||||||
|
const diagram = useDiagramStore();
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
const ui = useUIStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Diagram state
|
||||||
|
nodes: diagram.nodes,
|
||||||
|
edges: diagram.edges,
|
||||||
|
sourceCode: diagram.sourceCode,
|
||||||
|
edgeStyle: diagram.edgeStyle,
|
||||||
|
savedDiagrams: diagram.savedDiagrams,
|
||||||
|
generationComplexity: diagram.generationComplexity, // NEW
|
||||||
|
|
||||||
|
// Diagram actions
|
||||||
|
setNodes: diagram.setNodes,
|
||||||
|
setEdges: diagram.setEdges,
|
||||||
|
setSourceCode: diagram.setSourceCode,
|
||||||
|
setEdgeStyle: diagram.setEdgeStyle,
|
||||||
|
setGenerationComplexity: diagram.setGenerationComplexity, // NEW
|
||||||
|
onNodesChange: diagram.onNodesChange,
|
||||||
|
onEdgesChange: diagram.onEdgesChange,
|
||||||
|
onConnect: diagram.onConnect,
|
||||||
|
updateNodeData: diagram.updateNodeData,
|
||||||
|
updateNodeType: diagram.updateNodeType,
|
||||||
|
deleteNode: diagram.deleteNode,
|
||||||
|
saveDiagram: diagram.saveDiagram,
|
||||||
|
loadDiagram: diagram.loadDiagram,
|
||||||
|
deleteDiagram: diagram.deleteDiagram,
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
apiKey: settings.apiKey,
|
||||||
|
ollamaUrl: settings.ollamaUrl,
|
||||||
|
modelName: settings.modelName,
|
||||||
|
aiMode: settings.aiMode,
|
||||||
|
onlineProvider: settings.onlineProvider,
|
||||||
|
theme: settings.theme,
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
setApiKey: settings.setApiKey,
|
||||||
|
setOllamaUrl: settings.setOllamaUrl,
|
||||||
|
setModelName: settings.setModelName,
|
||||||
|
setAiMode: settings.setAiMode,
|
||||||
|
setOnlineProvider: settings.setOnlineProvider,
|
||||||
|
toggleTheme: settings.toggleTheme,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
selectedNode: ui.selectedNode,
|
||||||
|
leftPanelOpen: ui.leftPanelOpen,
|
||||||
|
rightPanelOpen: ui.rightPanelOpen,
|
||||||
|
focusMode: ui.focusMode,
|
||||||
|
activeFilters: ui.activeFilters,
|
||||||
|
isLoading: ui.isLoading,
|
||||||
|
error: ui.error,
|
||||||
|
|
||||||
|
// UI actions
|
||||||
|
setSelectedNode: ui.setSelectedNode,
|
||||||
|
setLeftPanelOpen: ui.setLeftPanelOpen,
|
||||||
|
setRightPanelOpen: ui.setRightPanelOpen,
|
||||||
|
setFocusMode: ui.setFocusMode,
|
||||||
|
toggleFilter: ui.toggleFilter,
|
||||||
|
setLoading: ui.setLoading,
|
||||||
|
setError: ui.setError,
|
||||||
|
|
||||||
|
// Combined reset
|
||||||
|
reset: () => {
|
||||||
|
diagram.reset();
|
||||||
|
ui.setSelectedNode(null);
|
||||||
|
ui.setError(null);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/store/settingsStore.ts
Normal file
114
src/store/settingsStore.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* Settings Store - Manages AI configuration and app settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { AIMode, OnlineProvider, Theme } from '../types';
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
// AI Configuration
|
||||||
|
aiMode: AIMode;
|
||||||
|
onlineProvider: OnlineProvider;
|
||||||
|
apiKey: string;
|
||||||
|
ollamaUrl: string;
|
||||||
|
modelName: string;
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme: Theme;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setAiMode: (mode: AIMode) => void;
|
||||||
|
setOnlineProvider: (provider: OnlineProvider) => void;
|
||||||
|
setApiKey: (key: string) => void;
|
||||||
|
setOllamaUrl: (url: string) => void;
|
||||||
|
setModelName: (name: string) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialTheme = (): Theme => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem('flowgen_theme');
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSettings = {
|
||||||
|
aiMode: 'offline' as AIMode,
|
||||||
|
onlineProvider: 'openai' as OnlineProvider,
|
||||||
|
apiKey: '',
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
modelName: 'llama3.2-vision',
|
||||||
|
theme: getInitialTheme(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialSettings,
|
||||||
|
|
||||||
|
setAiMode: (aiMode) => {
|
||||||
|
localStorage.setItem('flowgen_ai_mode', aiMode);
|
||||||
|
set({ aiMode });
|
||||||
|
},
|
||||||
|
|
||||||
|
setOnlineProvider: (onlineProvider) => {
|
||||||
|
localStorage.setItem('flowgen_online_provider', onlineProvider);
|
||||||
|
set({ onlineProvider });
|
||||||
|
},
|
||||||
|
|
||||||
|
setApiKey: (apiKey) => {
|
||||||
|
localStorage.setItem('flowgen_api_key', apiKey);
|
||||||
|
set({ apiKey });
|
||||||
|
},
|
||||||
|
|
||||||
|
setOllamaUrl: (ollamaUrl) => {
|
||||||
|
localStorage.setItem('flowgen_ollama_url', ollamaUrl);
|
||||||
|
set({ ollamaUrl });
|
||||||
|
},
|
||||||
|
|
||||||
|
setModelName: (modelName) => {
|
||||||
|
localStorage.setItem('flowgen_model_name', modelName);
|
||||||
|
set({ modelName });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme: () => {
|
||||||
|
const newTheme = get().theme === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.classList.toggle('dark', newTheme === 'dark');
|
||||||
|
localStorage.setItem('flowgen_theme', newTheme);
|
||||||
|
set({ theme: newTheme });
|
||||||
|
},
|
||||||
|
|
||||||
|
setTheme: (theme) => {
|
||||||
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
|
localStorage.setItem('flowgen_theme', theme);
|
||||||
|
set({ theme });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSettings: () => set(initialSettings),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'flowgen-settings-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
aiMode: state.aiMode,
|
||||||
|
onlineProvider: state.onlineProvider,
|
||||||
|
apiKey: state.apiKey,
|
||||||
|
ollamaUrl: state.ollamaUrl,
|
||||||
|
modelName: state.modelName,
|
||||||
|
theme: state.theme,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
// Apply theme on rehydration
|
||||||
|
if (state?.theme) {
|
||||||
|
document.documentElement.classList.toggle('dark', state.theme === 'dark');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
83
src/store/uiStore.ts
Normal file
83
src/store/uiStore.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* UI Store - Manages UI state and interactions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Node } from '../types';
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
// Panel states
|
||||||
|
leftPanelOpen: boolean;
|
||||||
|
rightPanelOpen: boolean;
|
||||||
|
focusMode: boolean;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
selectedNode: Node | null;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
activeFilters: string[];
|
||||||
|
|
||||||
|
// Loading & Error states
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setLeftPanelOpen: (open: boolean) => void;
|
||||||
|
setRightPanelOpen: (open: boolean) => void;
|
||||||
|
setFocusMode: (focusMode: boolean) => void;
|
||||||
|
toggleFocusMode: () => void;
|
||||||
|
|
||||||
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
|
||||||
|
toggleFilter: (filterId: string) => void;
|
||||||
|
setActiveFilters: (filters: string[]) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other'];
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>()((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
leftPanelOpen: true,
|
||||||
|
rightPanelOpen: false,
|
||||||
|
focusMode: false,
|
||||||
|
selectedNode: null,
|
||||||
|
activeFilters: DEFAULT_FILTERS,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Panel actions
|
||||||
|
setLeftPanelOpen: (leftPanelOpen) => set({ leftPanelOpen }),
|
||||||
|
setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }),
|
||||||
|
setFocusMode: (focusMode) => set({ focusMode }),
|
||||||
|
toggleFocusMode: () => set({ focusMode: !get().focusMode }),
|
||||||
|
|
||||||
|
// Selection actions
|
||||||
|
setSelectedNode: (selectedNode) => {
|
||||||
|
set({ selectedNode, rightPanelOpen: selectedNode !== null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Filter actions
|
||||||
|
toggleFilter: (filterId) => {
|
||||||
|
const { activeFilters } = get();
|
||||||
|
const isActive = activeFilters.includes(filterId);
|
||||||
|
set({
|
||||||
|
activeFilters: isActive
|
||||||
|
? activeFilters.filter((f) => f !== filterId)
|
||||||
|
: [...activeFilters, filterId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveFilters: (activeFilters) => set({ activeFilters }),
|
||||||
|
|
||||||
|
resetFilters: () => set({ activeFilters: DEFAULT_FILTERS }),
|
||||||
|
|
||||||
|
// Loading & Error actions
|
||||||
|
setLoading: (isLoading) => set({ isLoading }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
186
src/styles/theme.css
Normal file
186
src/styles/theme.css
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
:root {
|
||||||
|
/* --- Light Theme (Default) --- */
|
||||||
|
--theme-mode: light;
|
||||||
|
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-void: #f8fafc;
|
||||||
|
--bg-surface: #ffffff;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
--bg-elevated: #ffffff;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-tertiary: #94a3b8;
|
||||||
|
--text-muted: #cbd5e1;
|
||||||
|
|
||||||
|
/* Glass Effects */
|
||||||
|
--glass-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--glass-blur: blur(24px);
|
||||||
|
--glass-saturate: saturate(150%);
|
||||||
|
|
||||||
|
/* Brand Colors */
|
||||||
|
--brand-blue: #2563eb;
|
||||||
|
--brand-indigo: #4f46e5;
|
||||||
|
--brand-purple: #7c3aed;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--info: #3b82f6;
|
||||||
|
|
||||||
|
/* Void Scale (Light) */
|
||||||
|
--void-950: #f8fafc;
|
||||||
|
--void-900: #f1f5f9;
|
||||||
|
--void-800: #e2e8f0;
|
||||||
|
--void-700: #cbd5e1;
|
||||||
|
|
||||||
|
/* Interactions */
|
||||||
|
--interactive-hover: rgba(0, 0, 0, 0.05);
|
||||||
|
--interactive-active: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-base: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-cinematic: 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* --- Dark Theme (The Void) --- */
|
||||||
|
--theme-mode: dark;
|
||||||
|
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-void: #020617;
|
||||||
|
--bg-surface: #0f172a;
|
||||||
|
--bg-panel: #1e293b;
|
||||||
|
--bg-elevated: #334155;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-tertiary: #64748b;
|
||||||
|
--text-muted: #475569;
|
||||||
|
|
||||||
|
/* Glass Effects */
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-blur: blur(32px);
|
||||||
|
--glass-saturate: saturate(180%);
|
||||||
|
|
||||||
|
/* Brand Colors (Brighter for dark mode) */
|
||||||
|
--brand-blue: #3b82f6;
|
||||||
|
--brand-indigo: #6366f1;
|
||||||
|
--brand-purple: #8b5cf6;
|
||||||
|
|
||||||
|
/* Void Scale (Dark) */
|
||||||
|
--void-950: #020617;
|
||||||
|
/* Deepest Void */
|
||||||
|
--void-900: #0f172a;
|
||||||
|
/* Card Shadow Space */
|
||||||
|
--void-800: #1e293b;
|
||||||
|
/* Border Highlighting */
|
||||||
|
--void-700: #334155;
|
||||||
|
/* Elevated Elements */
|
||||||
|
|
||||||
|
/* Interactions */
|
||||||
|
--interactive-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--interactive-active: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
/* Shadows (More dramatic in dark mode) */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Global Transitions & Animations
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition-property: background-color, border-color, color, opacity;
|
||||||
|
transition-duration: var(--transition-fast);
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions for elements that shouldn't animate */
|
||||||
|
.no-transition,
|
||||||
|
.react-flow__node,
|
||||||
|
.react-flow__edge {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Utility Classes
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.bg-void {
|
||||||
|
background-color: var(--bg-void);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-surface {
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-panel {
|
||||||
|
background-color: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-tertiary {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Node Highlight Animation
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes node-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-highlight {
|
||||||
|
animation: node-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Fade In Animation
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
341
src/styles/ui.css
Normal file
341
src/styles/ui.css
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
/* ============================================
|
||||||
|
Core UI Components - Theme Aware
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Glass Panel - Frosted glass effect */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
backdrop-filter: var(--glass-blur) var(--glass-saturate);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur) var(--glass-saturate);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Glass - More prominent glass effect */
|
||||||
|
.floating-glass {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
backdrop-filter: var(--glass-blur) var(--glass-saturate);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur) var(--glass-saturate);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px -4px rgba(0, 0, 0, 0.15),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titanium Border - Subtle metallic border */
|
||||||
|
.titanium-border {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Button Hierarchy
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Primary Button - Main CTA */
|
||||||
|
.btn-primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
background: linear-gradient(135deg, var(--brand-blue), var(--brand-indigo));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px -8px rgba(59, 130, 246, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, transparent, rgba(255, 255, 255, 0.1));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 32px -8px rgba(59, 130, 246, 0.6),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0) scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Button */
|
||||||
|
.btn-secondary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--brand-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost Button - Minimal style */
|
||||||
|
.btn-ghost {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost.active {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--brand-blue);
|
||||||
|
border-color: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon Button - Square icon-only button */
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.active {
|
||||||
|
background: var(--brand-blue);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--brand-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Animations
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 4px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-glow {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Scrollbar Styling
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Hide scrollbars by default */
|
||||||
|
.hide-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for when visible */
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--void-700) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--void-700);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--void-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Form Elements
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.input-base {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bg-void);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-base:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand-blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-base::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Tooltip Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(-4px);
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: var(--void-800);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(-50%) translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Badge Styles
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-amber {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-red {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
42
src/test/setup.ts
Normal file
42
src/test/setup.ts
Normal file
|
|
@ -0,0 +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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
179
src/types/index.ts
Normal file
179
src/types/index.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
/**
|
||||||
|
* Core type definitions for kv-graph
|
||||||
|
* Centralized types to ensure consistency across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Node as FlowNode, Edge as FlowEdge, Connection as FlowConnection } from '@xyflow/react';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Node Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata attached to nodes for additional context
|
||||||
|
*/
|
||||||
|
export interface NodeMetadata {
|
||||||
|
techStack: string[];
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended Node type with application-specific data
|
||||||
|
*/
|
||||||
|
export interface NodeData {
|
||||||
|
label?: string;
|
||||||
|
metadata?: NodeMetadata;
|
||||||
|
category?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Node = FlowNode<NodeData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed node from Mermaid code
|
||||||
|
*/
|
||||||
|
export interface ParsedNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: NodeType;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NodeType =
|
||||||
|
| 'start'
|
||||||
|
| 'end'
|
||||||
|
| 'default'
|
||||||
|
| 'decision'
|
||||||
|
| 'process'
|
||||||
|
| 'database'
|
||||||
|
| 'group'
|
||||||
|
| 'client'
|
||||||
|
| 'server';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Edge Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type Edge = FlowEdge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed edge from Mermaid code
|
||||||
|
*/
|
||||||
|
export interface ParsedEdge {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label?: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed group/subgraph from Mermaid code
|
||||||
|
*/
|
||||||
|
export interface ParsedGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
nodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EdgeStyle = 'curved' | 'straight';
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AI Service Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type AIMode = 'online' | 'offline' | 'browser';
|
||||||
|
|
||||||
|
export type OnlineProvider = 'openai' | 'gemini' | 'ollama-cloud' | 'browser';
|
||||||
|
|
||||||
|
export interface AIResponse {
|
||||||
|
success: boolean;
|
||||||
|
mermaidCode?: string;
|
||||||
|
metadata?: Record<string, NodeMetadata>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIFixResponse {
|
||||||
|
success: boolean;
|
||||||
|
mermaidCode?: string;
|
||||||
|
explanation?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIMessage {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string | AIMessageContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIMessageContent {
|
||||||
|
type: 'text' | 'image_url';
|
||||||
|
text?: string;
|
||||||
|
image_url?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Diagram Persistence Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface SavedDiagram {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
sourceCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// UI State Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export type InputTab = 'image' | 'code' | 'describe';
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Connection Types (React Flow)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Re-export Connection from React Flow for type compatibility
|
||||||
|
export type Connection = FlowConnection;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Export Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type ExportFormat = 'png' | 'jpg' | 'svg' | 'json' | 'mermaid' | 'react' | 'react-minimal';
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
format: ExportFormat;
|
||||||
|
quality?: number;
|
||||||
|
pixelRatio?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Component Props Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface NodeProps {
|
||||||
|
data: NodeData;
|
||||||
|
selected: boolean;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomHandleProps {
|
||||||
|
type: 'source' | 'target';
|
||||||
|
position: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
105
src/types/visualOrganization.ts
Normal file
105
src/types/visualOrganization.ts
Normal file
|
|
@ -0,0 +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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
50
todo.md
Normal file
50
todo.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 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
|
||||||
62
tsconfig.app.json
Normal file
62
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Path Aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@/components/*": [
|
||||||
|
"./src/components/*"
|
||||||
|
],
|
||||||
|
"@/hooks/*": [
|
||||||
|
"./src/hooks/*"
|
||||||
|
],
|
||||||
|
"@/lib/*": [
|
||||||
|
"./src/lib/*"
|
||||||
|
],
|
||||||
|
"@/store/*": [
|
||||||
|
"./src/store/*"
|
||||||
|
],
|
||||||
|
"@/types/*": [
|
||||||
|
"./src/types/*"
|
||||||
|
],
|
||||||
|
"@/styles/*": [
|
||||||
|
"./src/styles/*"
|
||||||
|
],
|
||||||
|
"@/pages/*": [
|
||||||
|
"./src/pages/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||||
|
flow: ['@xyflow/react', 'dagre'],
|
||||||
|
editor: ['@monaco-editor/react'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
11
vitest.config.ts
Normal file
11
vitest.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
/// <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