feat: v1.1.0 release - integrated AI vision, smart tools, and themes

This commit is contained in:
SysVis AI 2025-12-27 21:04:13 +07:00
commit 0c0297ffee
72 changed files with 17571 additions and 0 deletions

16
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,174 @@
# 🔮 KV-Graph
**AI-Powered Diagram Editor** — Transform ideas into beautiful, interactive flowcharts using natural language, images, or Mermaid code.
![KV-Graph Demo](./public/demo.gif)
## ✨ 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

71
package.json Normal file
View 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
View 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

View 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."

View 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
View 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
View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
</>
);
}

View 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.

View 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>
);
};

View 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,
};

View 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>
);
}

View 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>
);
}

View 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,
};

View 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>
);
};

View 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>
);
};

View 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>
);
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View file

@ -0,0 +1,6 @@
/**
* Custom hooks exports
*/
export { useAIGeneration } from './useAIGeneration';
export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts';

View 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,
};
}

View 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 ? '' : '+');
}

View 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
View 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;
}

View 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');
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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
View 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,
},
});