Compare commits

...

11 Commits

Author SHA1 Message Date
1e174e81d3 Add local database mode for browser-only diagrams
Some checks failed
Node.js CI / build (push) Waiting to run
Build / build (push) Failing after 15m8s
- Add /db/local/:db path type that stores diagrams locally without syncing
- New diagrams now default to local storage (browser-only)
- Share button creates public copy when sharing local diagrams
- Add storage type badges (Local/Public/Private) in diagram manager
- Add GitHub Actions workflow for automated builds
- Block local- database requests at server with 404

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:13:43 -06:00
a772372b2b Add public URL sharing with express-pouchdb sync
- Add express-pouchdb for self-hosted PouchDB sync server
- Public databases (/db/public/:db) accessible without auth
- Add auth middleware for public/private database access
- Simplify share button to copy current URL to clipboard
- Move feature config from static JSON to dynamic API endpoint
- Add PouchDB sync to PouchData class for real-time collaboration
- Fix Express 5 compatibility by patching req.query
- Skip express.json() for /pouchdb routes (stream handling)
- Remove unused PouchdbPersistenceManager and old share system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:49:56 -06:00
74a2d179b9 Add Ollama as alternative AI provider and modify_connection tool
Ollama Integration:
- Add providerConfig.js for managing AI provider settings
- Add toolConverter.js to convert between Claude and Ollama formats
- Add ollama.js API handler with function calling support
- Update diagramAI.ts with Ollama models (llama3.1, mistral, qwen2.5)
- Route requests to appropriate provider based on selected model
- Use 127.0.0.1 to avoid IPv6 resolution issues

New modify_connection Tool:
- Add modify_connection tool to change connection labels and colors
- Support finding connections by label or by from/to entities
- Add chatModifyConnection event handler in diagramManager
- Clarify in tool descriptions that empty string removes labels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:08:17 -06:00
5891dfd6b7 Fix hamburger menu visibility and feature config format
- Fix z-index layering so hamburger menu appears above canvas
  - Lower canvas zIndex from 1000 to 1
  - Add zIndex={100} to Affix and Menu components
  - Add position="bottom-start" to prevent dropdown going off-screen

- Update feature configs to use string states instead of booleans
  - Convert all JSON configs from true/false to "on"/"off"/"coming-soon"/"pro"
  - Fix BASIC_FEATURE_CONFIG to enable core features for logged-in users
  - This fixes menu items not responding to clicks when authenticated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:00:37 -06:00
a7aa385d98 Add model selection and ground-projected directional placement
Model management:
- Add list_models, get_current_model, set_model tools
- Support Claude Sonnet 4, Opus 4, and Haiku 3.5
- Model selection persists for session duration

Directional placement improvements:
- Compute ground-projected forward/right vectors from camera
- Accounts for camera being parented to moving platform
- "Forward" means forward on ground plane, ignoring vertical look angle
- Pre-calculate example positions for left/right/forward/back
- Update system prompt to use get_camera_position for relative directions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:14:44 -06:00
c9dc61b918 Add camera position tool and fix entity modification bugs
- Add get_camera_position tool for positioning entities relative to user view
- Fix color change causing entities to disappear (dispose mesh before rebuild)
- Fix connections being lost when modifying entities (defer disposal, let
  scene observer re-find meshes after they're recreated with same ID)
- Add position and color setters to DiagramObject for real-time updates
- Add debug logging to diagramAI and claude.js for troubleshooting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:59:30 -06:00
2fd87b2d14 Fix entity connections and add clear diagram tool
Connection fixes:
- Add chatResolveEntity event to resolve labels to entity IDs
- Update connectEntities to resolve from/to labels before creating connection
- Auto-generate connection labels as "{from label} to {to label}"

Clear diagram tool:
- Add clear_diagram tool with confirmation requirement
- Claude prompts user for confirmation before executing
- Clears all entities from diagram and resets session
- Syncs empty entity list to server after clearing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 14:02:11 -06:00
7769910027 Add server-side session persistence for chat
Implement session management to maintain conversation history and entity
context across page refreshes. Sessions are stored in-memory and include:
- Conversation history (stored server-side, restored on reconnect)
- Entity snapshots synced before each message for LLM context
- Auto-injection of diagram state into Claude's system prompt

Key changes:
- Add session store service with create/resume/sync/clear operations
- Add session API endpoints (/api/session/*)
- Update Claude API to inject entity context and manage history
- Update ChatPanel to initialize sessions and sync entities
- Add debug endpoint for inspecting session state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 13:42:01 -06:00
1152ab0d0c Add Express API server for Claude API proxy
- Add Express server with vite-express for combined frontend/API serving
- Create modular API route structure (server/api/)
- Implement Claude API proxy with proper header injection
- Support split deployment via API_ONLY and ALLOWED_ORIGINS env vars
- Remove Claude proxy from Vite config (now handled by Express)
- Add migration plan documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 12:32:49 -06:00
1ccdab2780 Added chat interface. 2025-12-20 11:25:18 -06:00
e714c3d3df Added chat interface. 2025-12-20 11:25:14 -06:00
42 changed files with 7433 additions and 754 deletions

31
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: linux_amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build Front End
run: npm run build
env:
NODE_OPTIONS: '--max-old-space-size=4096'
- name: Restart Server
run: |
# Kill existing process on port 3001
lsof -ti:3001 | xargs kill -9 2>/dev/null || true
# Start server in background with nohup
nohup npm run start > /tmp/immersive.log 2>&1 &

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ dist-ssr
# Local Netlify folder # Local Netlify folder
.netlify .netlify
/data/

403
EXPRESS_API_PLAN.md Normal file
View File

@ -0,0 +1,403 @@
# Express.js API Server Plan
## Goal
Add an Express.js backend server to handle API routes (starting with Claude API), with support for either combined or split deployment.
## Advantages Over Next.js Migration
- **Minimal frontend changes** - only API URL configuration
- **No routing changes** - keep react-router-dom as-is
- **Flexible deployment** - combined or split frontend/backend
- **Already partially exists** - `server.js` in root has Express + vite-express scaffolding
## Deployment Options
### Option A: Combined (Single Server)
```
Express Server (vite-express)
├── Serves static files from dist/
└── Handles /api/* routes
```
- Simpler setup, one deployment
- Good for: VPS, Railway, Fly.io, DigitalOcean App Platform
### Option B: Split (Separate Hosts)
```
Static Host (CDN) API Server (Node.js)
├── Cloudflare Pages ├── Railway
├── Netlify ├── Fly.io
├── Vercel ├── AWS Lambda
└── S3 + CloudFront └── Any VPS
Serves dist/ Handles /api/*
```
- Better scalability, cheaper static hosting
- Good for: High traffic, global CDN distribution
---
## Current State
### Existing `server.js` (incomplete)
```javascript
import express from "express";
import ViteExpress from "vite-express";
import dotenv from "dotenv";
import expressProxy from "express-http-proxy";
dotenv.config();
const app = express();
app.use("/api", expressProxy("local.immersiveidea.com"));
ViteExpress.listen(app, process.env.PORT || 3001, () => console.log("Server is listening..."));
```
### Missing Dependencies
The following packages are imported but not in package.json:
- `express`
- `vite-express`
- `express-http-proxy`
- `dotenv`
---
## Implementation Plan
### Phase 1: Install Dependencies
```bash
npm install express vite-express dotenv cors
```
- `express` - Web framework
- `vite-express` - Vite integration for combined deployment
- `dotenv` - Environment variable loading
- `cors` - Cross-origin support for split deployment
### Phase 2: Create API Routes Structure
Create a modular API structure:
```
server/
├── server.js # Existing WebSocket server (keep as-is)
├── api/
│ ├── index.js # Main API router
│ └── claude.js # Claude API proxy route
```
### Phase 3: Update Root `server.js`
Replace the current incomplete server.js with:
```javascript
import express from "express";
import ViteExpress from "vite-express";
import cors from "cors";
import dotenv from "dotenv";
import apiRoutes from "./server/api/index.js";
dotenv.config();
const app = express();
// CORS configuration for split deployment
// In combined mode, same-origin requests don't need CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
if (allowedOrigins.length > 0) {
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
}
app.use(express.json());
// API routes
app.use("/api", apiRoutes);
// Check if running in API-only mode (split deployment)
const apiOnly = process.env.API_ONLY === "true";
if (apiOnly) {
// API-only mode: no static file serving
app.listen(process.env.PORT || 3000, () => {
console.log(`API server running on port ${process.env.PORT || 3000}`);
});
} else {
// Combined mode: Vite handles static files + SPA
ViteExpress.listen(app, process.env.PORT || 3001, () => {
console.log(`Server running on port ${process.env.PORT || 3001}`);
});
}
```
### Phase 4: Create API Router
**`server/api/index.js`**:
```javascript
import { Router } from "express";
import claudeRouter from "./claude.js";
const router = Router();
// Claude API proxy
router.use("/claude", claudeRouter);
// Health check
router.get("/health", (req, res) => {
res.json({ status: "ok" });
});
export default router;
```
**`server/api/claude.js`**:
```javascript
import { Router } from "express";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
router.post("/*", async (req, res) => {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return res.status(500).json({ error: "API key not configured" });
}
// Get the path after /api/claude (e.g., /v1/messages)
const path = req.params[0] || req.path;
try {
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(req.body),
});
const data = await response.json();
res.status(response.status).json(data);
} catch (error) {
console.error("Claude API error:", error);
res.status(500).json({ error: "Failed to proxy request to Claude API" });
}
});
export default router;
```
### Phase 5: Update Vite Config
Remove the Claude proxy from `vite.config.ts` since Express handles it now.
**Before** (lines 41-56):
```javascript
'^/api/claude': {
target: 'https://api.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const apiKey = env.ANTHROPIC_API_KEY;
// ...
});
}
}
```
**After**: Remove this block entirely. The Express server handles `/api/claude/*`.
Keep the other proxies (`/sync/*`, `/create-db`, `/api/images`) - they still proxy to deepdiagram.com in dev mode.
### Phase 6: Add API URL Configuration (for Split Deployment)
Create a utility to get the API base URL:
**`src/util/apiConfig.ts`**:
```typescript
// API base URL - empty string for same-origin (combined deployment)
// Set VITE_API_URL for split deployment (e.g., "https://api.yourdomain.com")
export const API_BASE_URL = import.meta.env.VITE_API_URL || '';
export function apiUrl(path: string): string {
return `${API_BASE_URL}${path}`;
}
```
**Update `src/react/services/diagramAI.ts`**:
```typescript
import { apiUrl } from '../../util/apiConfig';
// Change from:
const response = await fetch('/api/claude/v1/messages', { ... });
// To:
const response = await fetch(apiUrl('/api/claude/v1/messages'), { ... });
```
This change is backward-compatible:
- **Combined deployment**: `VITE_API_URL` is empty, calls go to same origin
- **Split deployment**: `VITE_API_URL=https://api.example.com`, calls go to API server
### Phase 7: Update package.json Scripts
```json
"scripts": {
"dev": "node server.js",
"build": "node versionBump.js && vite build",
"start": "NODE_ENV=production node server.js",
"start:api": "API_ONLY=true node server.js",
"test": "vitest",
"socket": "node server/server.js",
"serverBuild": "cd server && tsc"
}
```
**Changes:**
- `dev`: Runs Express + vite-express (serves Vite in dev mode)
- `start`: Combined mode - serves dist/ + API
- `start:api`: API-only mode for split deployment
- Removed `preview` (use `start` instead)
---
## File Changes Summary
| Action | File | Description |
|--------|------|-------------|
| Modify | `package.json` | Add dependencies, update scripts |
| Modify | `server.js` | Full Express server with CORS + API routes |
| Create | `server/api/index.js` | Main API router |
| Create | `server/api/claude.js` | Claude API proxy endpoint |
| Create | `src/util/apiConfig.ts` | API URL configuration utility |
| Modify | `src/react/services/diagramAI.ts` | Use apiUrl() for API calls |
| Modify | `vite.config.ts` | Remove `/api/claude` proxy block |
---
## How vite-express Works
`vite-express` is a simple integration that:
1. **Development**: Runs Vite's dev server as middleware, providing HMR
2. **Production**: Serves the built `dist/` folder as static files
This means:
- One server handles both API and frontend
- No CORS issues (same origin)
- HMR works in development
- Production-ready with `vite build`
---
## Production Deployment
### Option A: Combined Deployment
Single server handles both frontend and API:
```bash
# Build frontend
npm run build
# Start combined server (serves dist/ + API)
npm run start
```
**Environment variables (.env)**:
```bash
PORT=3001
ANTHROPIC_API_KEY=sk-ant-...
```
The Express server will:
1. Handle `/api/*` routes directly
2. Serve static files from `dist/`
3. Fall back to `dist/index.html` for SPA routing
### Option B: Split Deployment
Separate hosting for frontend (CDN) and API (Node server):
**API Server:**
```bash
# Start API-only server
npm run start:api
```
**Environment variables (.env for API server)**:
```bash
PORT=3000
API_ONLY=true
ANTHROPIC_API_KEY=sk-ant-...
ALLOWED_ORIGINS=https://your-frontend.com,https://www.your-frontend.com
```
**Frontend (Static Host):**
```bash
# Build with API URL configured
VITE_API_URL=https://api.yourdomain.com npm run build
# Deploy dist/ to your static host (Cloudflare Pages, Netlify, etc.)
```
**Environment variables (.env.production for frontend build)**:
```bash
VITE_API_URL=https://api.yourdomain.com
```
### Deployment Examples
| Deployment | Frontend | API Server | Cost |
|------------|----------|------------|------|
| Combined | Railway | (same) | ~$5/mo |
| Combined | Fly.io | (same) | Free tier |
| Split | Cloudflare Pages (free) | Railway ($5/mo) | ~$5/mo |
| Split | Netlify (free) | Fly.io (free) | Free |
| Split | Vercel (free) | AWS Lambda | Pay-per-use |
---
## Future API Routes
To add more API routes, create new files in `server/api/`:
```javascript
// server/api/index.js
import claudeRouter from "./claude.js";
import imagesRouter from "./images.js"; // future
import authRouter from "./auth.js"; // future
router.use("/claude", claudeRouter);
router.use("/images", imagesRouter);
router.use("/auth", authRouter);
```
---
## Migration Order
1. `npm install express vite-express dotenv cors`
2. Create `server/api/index.js`
3. Create `server/api/claude.js`
4. Create `src/util/apiConfig.ts`
5. Update `src/react/services/diagramAI.ts` to use `apiUrl()`
6. Update `server.js` (root) with full Express + CORS setup
7. Remove `/api/claude` proxy from `vite.config.ts`
8. Update `package.json` scripts
9. Test combined: `npm run dev` and verify Claude API works
10. (Optional) Test split: Set `VITE_API_URL` and `API_ONLY=true`
---
## Notes
- **WebSocket server unchanged**: `server/server.js` (port 8080) runs separately
- **Minimal frontend changes**: Only `diagramAI.ts` updated to use `apiUrl()`
- **Environment variables**: `ANTHROPIC_API_KEY` already in `.env.local`
- **Node version**: Requires Node 18+ for native `fetch`
- **CORS**: Only enabled when `ALLOWED_ORIGINS` is set (split deployment)
- **Backward compatible**: Works as combined deployment by default

167
NEXT_MIGRATION_PLAN.md Normal file
View File

@ -0,0 +1,167 @@
# Vite to Next.js Migration Plan
## Goal
Migrate from Vite to Next.js App Router to get proper API route support, with minimal changes to existing code.
## Configuration
- **Router**: App Router with `'use client'` on all pages
- **Rendering**: CSR only (no SSR) - simplifies migration since BabylonJS can't SSR
- **API Routes**: Claude API now, structured for future expansion
- **External Proxies**: Keep sync/create-db/images as Next.js rewrites to deepdiagram.com
---
## Phase 1: Setup (No Breaking Changes)
### 1.1 Install Next.js
```bash
npm install next
```
### 1.2 Create `next.config.js`
```javascript
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{ source: '/sync/:path*', destination: 'https://www.deepdiagram.com/sync/:path*' },
{ source: '/create-db', destination: 'https://www.deepdiagram.com/create-db' },
{ source: '/api/images', destination: 'https://www.deepdiagram.com/api/images' },
];
},
webpack: (config, { isServer }) => {
config.experiments = { ...config.experiments, asyncWebAssembly: true };
return config;
},
};
module.exports = nextConfig;
```
### 1.3 Update `tsconfig.json`
Add path alias:
```json
"baseUrl": ".",
"paths": { "@/*": ["./*"] }
```
---
## Phase 2: Create New Files
### 2.1 `src/react/providers.tsx` (extract from webApp.tsx)
- Move Auth0Provider and FeatureProvider wrapping here
- Add `'use client'` directive
- Handle window/document checks for SSR safety
### 2.2 `app/layout.tsx`
- Root layout with html/body tags
- Metadata (title, favicon from current index.html)
- Import global CSS
### 2.3 `app/globals.css`
```css
@import '../src/react/styles.css';
@import '@mantine/core/styles.css';
```
### 2.4 `app/api/claude/[...path]/route.ts`
- POST handler that proxies to api.anthropic.com
- Injects `ANTHROPIC_API_KEY` from env
- Adds `x-api-key` and `anthropic-version` headers
### 2.5 Page files (all with `'use client'`)
| Route | File | Component |
|-------|------|-----------|
| `/` | `app/page.tsx` | About |
| `/documentation` | `app/documentation/page.tsx` | Documentation |
| `/examples` | `app/examples/page.tsx` | Examples |
| `/pricing` | `app/pricing/page.tsx` | Pricing |
| `/db/[visibility]/[db]` | `app/db/[visibility]/[db]/page.tsx` | VrExperience |
| 404 | `app/not-found.tsx` | NotFound |
### 2.6 `src/react/components/ProtectedPage.tsx`
- Next.js version of route protection
- Uses `useRouter` from `next/navigation` for redirects
---
## Phase 3: Modify Existing Files
### 3.1 `src/react/pages/vrExperience.tsx`
**Changes:**
- Remove `useParams()` from react-router-dom
- Accept `visibility` and `db` as props instead
- Replace `useNavigate()` with `useRouter()` from `next/navigation`
### 3.2 `src/react/pageHeader.tsx`
**Changes:**
- Replace `import {Link} from "react-router-dom"` with `import Link from "next/link"`
- Change `to={item.href}` to `href={item.href}` on Link components
### 3.3 `src/react/marketing/about.tsx`
**Changes:**
- Replace `useNavigate()` with `useRouter()` from `next/navigation`
- Change `navigate('/path')` to `router.push('/path')`
### 3.4 `package.json`
```json
"scripts": {
"dev": "next dev -p 3001",
"build": "node versionBump.js && next build",
"start": "next start -p 3001",
"test": "vitest",
"socket": "node server/server.js",
"serverBuild": "cd server && tsc"
}
```
---
## Phase 4: Delete Old Files
| File | Reason |
|------|--------|
| `vite.config.ts` | Replaced by next.config.js |
| `index.html` | Next.js generates HTML |
| `src/webApp.ts` | Entry point no longer needed |
| `src/react/webRouter.tsx` | Replaced by app/ routing |
| `src/react/webApp.tsx` | Logic moved to providers.tsx |
| `src/react/components/ProtectedRoute.tsx` | Replaced by ProtectedPage.tsx |
---
## Critical Files to Modify
- `src/react/pages/vrExperience.tsx` - useParams -> props
- `src/react/pageHeader.tsx` - react-router Link -> Next.js Link
- `src/react/marketing/about.tsx` - useNavigate -> useRouter
- `src/react/webApp.tsx` - extract to providers.tsx
- `package.json` - scripts update
- `tsconfig.json` - path aliases
---
## Migration Order
1. Install next, create next.config.js
2. Update tsconfig.json
3. Create app/globals.css
4. Create src/react/providers.tsx
5. Create app/layout.tsx
6. Create app/api/claude/[...path]/route.ts
7. Create src/react/components/ProtectedPage.tsx
8. Modify vrExperience.tsx (accept props)
9. Create all app/*/page.tsx files
10. Modify pageHeader.tsx (Next.js Link)
11. Modify about.tsx (useRouter)
12. Update package.json scripts
13. Delete old files (vite.config.ts, index.html, webApp.ts, webRouter.tsx, webApp.tsx)
14. Test all routes
---
## Notes
- **Havok WASM**: Move `HavokPhysics.wasm` to `public/` folder
- **react-router-dom**: Can be removed from dependencies after migration
- **vite devDependencies**: Can be removed (vite, vite-plugin-cp)

150
SHARING_PLAN.md Normal file
View File

@ -0,0 +1,150 @@
# Self-Hosted Diagram Sharing with Express-PouchDB
## Requirements (Confirmed)
- **Storage**: In-memory (ephemeral) - lost on server restart
- **Content**: Copy current diagram entities when creating share
- **Expiration**: No expiration - links work until server restart
- **Encryption**: None - keep it simple, anyone with link can access
## Architecture
```
┌─────────────────────────────────────────────────┐
│ Express Server (port 3001) │
├─────────────────────────────────────────────────┤
│ /api/share/* → Share management API │
│ /pouchdb/* → express-pouchdb (sync) │
│ /share/:uuid → Client share route │
│ /api/* → Existing API routes │
│ /* → Vite static files │
└─────────────────────────────────────────────────┘
└── In-Memory PouchDB (per share UUID)
```
## Implementation Steps
### Phase 1: Server-Side Setup
#### 1.1 Add Dependencies
```bash
npm install express-pouchdb pouchdb-adapter-memory
```
#### 1.2 Create PouchDB Server Service
**New file: `server/services/pouchdbServer.js`**
- Initialize PouchDB with memory adapter
- Track active share databases in a Map
- Export `getShareDB(shareId)`, `shareExists(shareId)`, `createPouchDBMiddleware()`
#### 1.3 Create Share API
**New file: `server/api/share.js`**
- `POST /api/share/create` - Generate UUID, create in-memory DB, copy entities
- `GET /api/share/:id/exists` - Check if share exists
- `GET /api/share/stats` - Debug endpoint for active shares
#### 1.4 Update API Router
**Edit: `server/api/index.js`**
- Add `import shareRouter from "./share.js"`
- Mount at `router.use("/share", shareRouter)`
#### 1.5 Mount Express-PouchDB
**Edit: `server.js`**
- Import `createPouchDBMiddleware` from pouchdbServer.js
- Mount at `app.use("/pouchdb", createPouchDBMiddleware())`
---
### Phase 2: Client-Side Integration
#### 2.1 Update URL Parsing
**Edit: `src/util/functions/getPath.ts`**
- Add `getPathInfo()` function returning `{ dbName, isShare, shareId }`
- Detect `/share/:uuid` pattern
#### 2.2 Update PouchDB Persistence Manager
**Edit: `src/integration/database/pouchdbPersistenceManager.ts`**
In `initLocal()`:
- Call `getPathInfo()` to detect share URLs
- If share: use `share-{uuid}` as local DB name, call `beginShareSync()`
Add new method `beginShareSync(shareId)`:
- Check share exists via `/api/share/:id/exists`
- Connect to `${origin}/pouchdb/share-${shareId}`
- Set up presence with `share-${shareId}` as DB name
- Begin live sync (no encryption)
#### 2.3 Add React Route
**Edit: `src/react/webRouter.tsx`**
- Add route `{ path: "/share/:uuid", element: <VrExperience isShare={true} /> }`
- No ProtectedRoute wrapper (public access)
#### 2.4 Add Share Button Handler
**Edit: `src/react/pages/vrExperience.tsx`**
- Add `isShare` prop
- Add `handleShare()` function:
1. Get all entities from local PouchDB
2. POST to `/api/share/create` with entities
3. Copy resulting URL to clipboard
4. Show confirmation
---
### Phase 3: Presence Integration
The WebSocket presence system already routes by database name. Since shares use `share-{uuid}` as the database name, presence works automatically.
**Edit: `server/server.js`** (WebSocket server)
- Update `originIsAllowed()` to allow localhost for development
---
## Files to Modify
| File | Action | Purpose |
|------|--------|---------|
| `package.json` | Edit | Add express-pouchdb, pouchdb-adapter-memory |
| `server.js` | Edit | Mount /pouchdb middleware |
| `server/api/index.js` | Edit | Add share router |
| `server/services/pouchdbServer.js` | Create | PouchDB memory initialization |
| `server/api/share.js` | Create | Share API endpoints |
| `server/server.js` | Edit | Allow localhost origins |
| `src/util/functions/getPath.ts` | Edit | Add getPathInfo() |
| `src/integration/database/pouchdbPersistenceManager.ts` | Edit | Add share sync logic |
| `src/react/webRouter.tsx` | Edit | Add /share/:uuid route |
| `src/react/pages/vrExperience.tsx` | Edit | Add share button handler |
| `src/util/featureConfig.ts` | Edit | Enable shareCollaborate feature |
---
## User Flow
### Creating a Share
1. User has a diagram open at `/db/public/mydiagram`
2. Clicks "Share" button
3. Client fetches all entities from local PouchDB
4. POSTs to `/api/share/create` with entities
5. Server creates in-memory DB, copies entities, returns UUID
6. Client copies `https://server.com/share/{uuid}` to clipboard
7. User shares link with collaborators
### Joining a Share
1. User navigates to `https://server.com/share/{uuid}`
2. React Router renders VrExperience with `isShare=true`
3. PouchdbPersistenceManager detects share URL
4. Checks `/api/share/:uuid/exists` - returns true
5. Creates local PouchDB `share-{uuid}`
6. Connects to `/pouchdb/share-{uuid}` for sync
7. Entities replicate to local, render in scene
8. Presence WebSocket connects with `share-{uuid}` as room
---
## Future Authentication (Not Implemented Now)
Structure allows easy addition later:
- express-pouchdb middleware can be wrapped with auth middleware
- Share API can require JWT/session tokens
- Could add password-protected shares
- Could add read-only vs read-write permissions

179
SYNC_PLAN.md Normal file
View File

@ -0,0 +1,179 @@
# Future Sync Strategy: Keeping Local and Public Clones in Sync
## Current State (v1)
- Sharing creates a **ONE-TIME COPY** from local to public
- Copies diverge independently after sharing
- No automatic sync between local and public versions
- Local diagrams are browser-only (IndexedDB via PouchDB)
- Public diagrams sync with server via express-pouchdb
### URL Scheme
| Route | Sync | Access | Status |
|-------|------|--------|--------|
| `/db/local/:id` | None | Browser-only | Implemented |
| `/db/public/:id` | Yes | Anyone | Implemented |
| `/db/private/:id` | Yes | Authorized users | Route only (no auth) |
## Future Options
### Option 1: Manual Push/Pull (Recommended for v2)
Add explicit user-triggered sync between local and public copies.
**Features:**
- "Push to Public" button - sends local changes to public copy
- "Pull from Public" button - gets public changes into local
- Track `lastSyncedAt` timestamp
- Show indicator when copies have diverged
- Conflict resolution: Last write wins (simple) or user choice (advanced)
**Pros:**
- User stays in control
- Clear mental model
- Simple to implement incrementally
**Cons:**
- Manual effort required
- Risk of forgetting to sync
### Option 2: Automatic Background Sync
Continuous bidirectional sync between local and public copies.
**Features:**
- Real-time sync like Google Docs
- Works across devices
- Offline-first with automatic merge
**Pros:**
- Seamless experience
- Always up to date
**Cons:**
- Complex conflict resolution (may need CRDTs)
- Higher performance overhead
- Harder to reason about state
### Option 3: Fork/Branch Model
One-way relationship: local is "draft", public is "published".
**Features:**
- Push only (local → public)
- No pull mechanism
- Public is the "source of truth" once published
**Pros:**
- Clear mental model
- No merge conflicts
- Simple implementation
**Cons:**
- Cannot incorporate public changes back to local
- Multiple people can't collaborate on draft
## Recommended Implementation (v2)
Implement **Option 1 (Manual Push/Pull)** as it provides the best balance of user control and simplicity.
### Data Model Changes
Add to diagram directory entry:
```typescript
interface DiagramEntry {
_id: string;
name: string;
description: string;
storageType: 'local' | 'public' | 'private';
createdAt: string;
// New fields for sync tracking
publicCopyId?: string; // ID of the public clone (if shared)
lastPushedAt?: string; // When changes were last pushed to public
lastPulledAt?: string; // When public changes were last pulled
publicVersion?: number; // Version number of public copy at last sync
}
```
### API Endpoints
```typescript
// Push local changes to public
POST /api/sync/push
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Pull public changes to local
POST /api/sync/pull
Body: { localDbName: string, publicDbName: string }
Response: { success: boolean, documentsUpdated: number }
// Check if copies have diverged
GET /api/sync/status?local={localDbName}&public={publicDbName}
Response: {
diverged: boolean,
localChanges: number,
publicChanges: number,
lastSyncedAt: string
}
```
### UI Components
1. **Sync Status Indicator**
- Shows in header when viewing a local diagram that has a public copy
- Green check: In sync
- Orange dot: Changes pending
- Red warning: Conflicts detected
2. **Push/Pull Buttons**
- In hamburger menu under "Share" section
- "Push to Public" - shows confirmation with change count
- "Pull from Public" - shows confirmation with change count
3. **Divergence Warning Badge**
- Shows on diagram card in Manage Diagrams modal
- Indicates when local and public have diverged
4. **Conflict Resolution Dialog**
- Shows when both local and public have changes to same entity
- Options: Keep Local, Keep Public, Keep Both (creates duplicate)
### Implementation Phases
**Phase 1: Tracking**
- Add `publicCopyId` when sharing local → public
- Track sharing relationship in directory
**Phase 2: Push**
- Implement push from local to public
- Overwrite public with local changes
- Update `lastPushedAt` timestamp
**Phase 3: Pull**
- Implement pull from public to local
- Merge public changes into local
- Update `lastPulledAt` timestamp
**Phase 4: Status**
- Implement divergence detection
- Add UI indicators
- Show sync status in Manage Diagrams
**Phase 5: Conflict Resolution**
- Detect entity-level conflicts
- Show resolution dialog
- Allow user to choose resolution strategy
## Migration Notes
Existing diagrams without `storageType` are treated as `public` for backwards compatibility. When such diagrams are loaded, the UI should work correctly but sync tracking features won't be available until the diagram metadata is updated.
## Security Considerations
- Push/pull operations should validate that the user has access to both databases
- Public databases remain world-readable/writable
- Private database sync will require authentication tokens
- Rate limiting should be applied to sync operations

2964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,19 @@
{ {
"name": "immersive", "name": "immersive",
"private": true, "private": true,
"version": "0.0.8-34", "version": "0.0.8-43",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "node server.js",
"test": "vitest", "test": "vitest",
"build": "node versionBump.js && vite build", "build": "node versionBump.js && vite build",
"preview": "vite preview", "start": "NODE_ENV=production node server.js",
"start:api": "API_ONLY=true node server.js",
"socket": "node server/server.js", "socket": "node server/server.js",
"serve": "node server.js",
"serverBuild": "cd server && tsc", "serverBuild": "cd server && tsc",
"havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps" "havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps"
}, },
@ -27,38 +27,46 @@
"@babylonjs/materials": "^8.16.2", "@babylonjs/materials": "^8.16.2",
"@babylonjs/serializers": "^8.16.2", "@babylonjs/serializers": "^8.16.2",
"@emotion/react": "^11.13.0", "@emotion/react": "^11.13.0",
"@giphy/js-fetch-api": "^5.6.0",
"@giphy/react-components": "^9.6.0",
"@mantine/core": "^7.17.8", "@mantine/core": "^7.17.8",
"@mantine/form": "^7.17.8", "@mantine/form": "^7.17.8",
"@mantine/hooks": "^7.17.8", "@mantine/hooks": "^7.17.8",
"@giphy/react-components": "^9.6.0",
"@giphy/js-fetch-api": "^5.6.0",
"@maptiler/client": "1.8.1", "@maptiler/client": "1.8.1",
"@picovoice/cobra-web": "^2.0.3", "@picovoice/cobra-web": "^2.0.3",
"@picovoice/eagle-web": "^1.0.0", "@picovoice/eagle-web": "^1.0.0",
"@picovoice/web-voice-processor": "^4.0.9", "@picovoice/web-voice-processor": "^4.0.9",
"@tabler/icons-react": "^3.14.0",
"@types/node": "^18.14.0", "@types/node": "^18.14.0",
"@types/react": "^18.2.72", "@types/react": "^18.2.72",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"axios": "^1.10.0", "axios": "^1.10.0",
"canvas-hypertxt": "1.0.3", "canvas-hypertxt": "1.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"events": "^3.3.0", "events": "^3.3.0",
"express": "^5.2.1",
"express-pouchdb": "^4.2.0",
"hash-wasm": "4.11.0", "hash-wasm": "4.11.0",
"hls.js": "^1.1.4", "hls.js": "^1.1.4",
"js-crypto-aes": "1.0.6", "js-crypto-aes": "1.0.6",
"leveldown": "^6.1.1",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"meaningful-string": "^1.4.0", "meaningful-string": "^1.4.0",
"peer-lite": "2.0.2", "peer-lite": "2.0.2",
"use-pouchdb": "^2.0.2",
"pouchdb": "^8.0.1", "pouchdb": "^8.0.1",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-adapter-memory": "^9.0.0",
"pouchdb-find": "^8.0.1", "pouchdb-find": "^8.0.1",
"query-string": "^8.1.0", "query-string": "^8.1.0",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"@tabler/icons-react": "^3.14.0",
"recordrtc": "^5.6.0", "recordrtc": "^5.6.0",
"rfc4648": "^1.5.3", "rfc4648": "^1.5.3",
"round": "^2.0.1", "round": "^2.0.1",
"uint8-to-b64": "^1.0.2", "uint8-to-b64": "^1.0.2",
"use-pouchdb": "^2.0.2",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vite-express": "^0.21.1",
"websocket": "^1.0.34", "websocket": "^1.0.34",
"websocket-ts": "^2.1.5" "websocket-ts": "^2.1.5"
}, },

View File

@ -1,89 +0,0 @@
# Feature Configuration Testing
This directory contains static JSON files for testing different user tiers locally.
## Available Configurations
### Default: `features` (none tier)
- **What you see**: Only the home page
- **All pages and features**: Disabled
- **Use case**: Unauthenticated users or when API is unavailable
### Free Tier: `features-free.json`
- **Pages**: All marketing pages + VR Experience
- **Features**: Basic diagram creation, management, immersive mode
- **Limits**: 6 diagrams max, 100MB storage
- **No access to**: Templates, private/encrypted designs, collaboration
### Basic Tier: `features-basic.json`
- **Pages**: All pages available
- **Features**: Free features + templates + private designs
- **Limits**: 25 diagrams max, 500MB storage
- **No access to**: Encrypted designs, collaboration
### Pro Tier: `features-pro.json`
- **Pages**: All pages available
- **Features**: Everything unlocked
- **Limits**: Unlimited (indicated by -1)
## How to Test Locally
### Method 1: Copy the file you want to test
```bash
# Test free tier
cp public/api/user/features-free.json public/api/user/features
# Test basic tier
cp public/api/user/features-basic.json public/api/user/features
# Test pro tier
cp public/api/user/features-pro.json public/api/user/features
# Test none/default (locked down)
cp public/api/user/features-none.json public/api/user/features
```
### Method 2: Symlink (easier for switching)
```bash
# Remove the default file
rm public/api/user/features
# Create a symlink to the tier you want to test
ln -s features-free.json public/api/user/features
# or
ln -s features-basic.json public/api/user/features
# or
ln -s features-pro.json public/api/user/features
```
## What Changes Between Tiers
| Feature | None | Free | Basic | Pro |
|---------|------|------|-------|-----|
| Pages (Examples, Docs, Pricing) | ❌ | ✅ | ✅ | ✅ |
| VR Experience | ❌ | ✅ | ✅ | ✅ |
| Create Diagram | ❌ | ✅ | ✅ | ✅ |
| Create From Template | ❌ | ❌ | ✅ | ✅ |
| Private Designs | ❌ | ❌ | ✅ | ✅ |
| Encrypted Designs | ❌ | ❌ | ❌ | ✅ |
| Share/Collaborate | ❌ | ❌ | ❌ | ✅ |
| Max Diagrams | 0 | 6 | 25 | ∞ |
| Storage | 0 | 100MB | 500MB | ∞ |
## Backend Implementation (Future)
When you're ready to implement the backend, create an endpoint at:
```
GET https://www.deepdiagram.com/api/user/features
```
The endpoint should:
1. Validate the Auth0 JWT token from `Authorization: Bearer <token>` header
2. Query the user's subscription tier from your database
3. Return JSON matching one of these structures based on their tier
4. Handle errors gracefully (401 for invalid token, 403 for unauthorized)
The frontend will automatically fall back to the static `features` file if:
- User is not authenticated
- API returns an error
- Network request fails

View File

@ -1,26 +0,0 @@
{
"tier": "none",
"pages": {
"examples": false,
"documentation": false,
"pricing": false,
"vrExperience": true
},
"features": {
"createDiagram": false,
"createFromTemplate": false,
"manageDiagrams": false,
"shareCollaborate": false,
"privateDesigns": false,
"encryptedDesigns": false,
"editData": false,
"config": true,
"enterImmersive": true,
"launchMetaQuest": true
},
"limits": {
"maxDiagrams": 0,
"maxCollaborators": 0,
"storageQuotaMB": 0
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "basic",
"pages": {
"examples": true,
"documentation": true,
"pricing": true,
"vrExperience": true
},
"features": {
"createDiagram": true,
"createFromTemplate": true,
"manageDiagrams": true,
"shareCollaborate": false,
"privateDesigns": true,
"encryptedDesigns": false,
"editData": true,
"config": true,
"enterImmersive": true,
"launchMetaQuest": true
},
"limits": {
"maxDiagrams": 25,
"maxCollaborators": 0,
"storageQuotaMB": 500
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "free",
"pages": {
"examples": true,
"documentation": true,
"pricing": true,
"vrExperience": true
},
"features": {
"createDiagram": true,
"createFromTemplate": false,
"manageDiagrams": true,
"shareCollaborate": false,
"privateDesigns": false,
"encryptedDesigns": false,
"editData": true,
"config": true,
"enterImmersive": true,
"launchMetaQuest": true
},
"limits": {
"maxDiagrams": 6,
"maxCollaborators": 0,
"storageQuotaMB": 100
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "none",
"pages": {
"examples": false,
"documentation": false,
"pricing": false,
"vrExperience": false
},
"features": {
"createDiagram": false,
"createFromTemplate": false,
"manageDiagrams": false,
"shareCollaborate": false,
"privateDesigns": false,
"encryptedDesigns": false,
"editData": false,
"config": false,
"enterImmersive": false,
"launchMetaQuest": false
},
"limits": {
"maxDiagrams": 0,
"maxCollaborators": 0,
"storageQuotaMB": 0
}
}

View File

@ -1,26 +0,0 @@
{
"tier": "pro",
"pages": {
"examples": true,
"documentation": true,
"pricing": true,
"vrExperience": true
},
"features": {
"createDiagram": true,
"createFromTemplate": true,
"manageDiagrams": true,
"shareCollaborate": true,
"privateDesigns": true,
"encryptedDesigns": true,
"editData": true,
"config": true,
"enterImmersive": true,
"launchMetaQuest": true
},
"limits": {
"maxDiagrams": -1,
"maxCollaborators": -1,
"storageQuotaMB": -1
}
}

View File

@ -1,13 +1,92 @@
import express from "express"; import express from "express";
import ViteExpress from "vite-express"; import ViteExpress from "vite-express";
import cors from "cors";
import dotenv from "dotenv"; import dotenv from "dotenv";
import expressProxy from "express-http-proxy"; import apiRoutes from "./server/api/index.js";
import { pouchApp, PouchDB } from "./server/services/databaseService.js";
import { dbAuthMiddleware } from "./server/middleware/dbAuth.js";
// Load .env.local first, then fall back to .env
dotenv.config({ path: '.env.local' });
dotenv.config(); dotenv.config();
const app = express(); const app = express();
app.use("/api", expressProxy("local.immersiveidea.com"));
ViteExpress.listen(app, process.env.PORT || 3001, () => console.log("Server is listening...")); // CORS configuration for split deployment
// In combined mode, same-origin requests don't need CORS
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(",") || [];
if (allowedOrigins.length > 0) {
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
}
// Parse JSON for all routes EXCEPT /pouchdb (express-pouchdb handles its own body parsing)
app.use((req, res, next) => {
if (req.path.startsWith('/pouchdb')) {
return next();
}
express.json()(req, res, next);
});
// API routes
app.use("/api", apiRoutes);
// Test endpoint to verify PouchDB is working
app.get("/pouchdb-test/:dbname", async (req, res) => {
try {
const dbName = req.params.dbname;
console.log(`[Test] Creating database: ${dbName}`);
const db = new PouchDB(dbName);
const info = await db.info();
console.log(`[Test] Database info:`, info);
// Try to add a test doc
const result = await db.put({ _id: 'test-doc', hello: 'world' });
console.log(`[Test] Added doc:`, result);
// Read it back
const doc = await db.get('test-doc');
console.log(`[Test] Got doc:`, doc);
res.json({ success: true, info, doc });
} catch (err) {
console.error(`[Test] Error:`, err);
res.status(500).json({ error: err.message, stack: err.stack });
}
});
// PouchDB database sync endpoint with auth middleware
// Public databases (/pouchdb/public-*) are accessible without auth
// Private databases (/pouchdb/private-*) require authentication
// Patch req.query for Express 5 compatibility with express-pouchdb
app.use("/pouchdb", dbAuthMiddleware, (req, res, next) => {
// Express 5 makes req.query read-only, but express-pouchdb needs to write to it
// Redefine as writable property
Object.defineProperty(req, 'query', {
value: { ...req.query },
writable: true,
configurable: true
});
next();
}, pouchApp, (err, req, res, next) => {
// Error handler for express-pouchdb
console.error('[PouchDB Error]', err);
res.status(500).json({ error: err.message, stack: err.stack });
});
// Check if running in API-only mode (split deployment)
const apiOnly = process.env.API_ONLY === "true";
if (apiOnly) {
// API-only mode: no static file serving
app.listen(process.env.PORT || 3000, () => {
console.log(`API server running on port ${process.env.PORT || 3000}`);
});
} else {
// Combined mode: Vite handles static files + SPA
ViteExpress.listen(app, process.env.PORT || 3001, () => {
console.log(`Server running on port ${process.env.PORT || 3001}`);
});
}

143
server/api/claude.js Normal file
View File

@ -0,0 +1,143 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
const router = Router();
const ANTHROPIC_API_URL = "https://api.anthropic.com";
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
// Express 5 uses named parameters for wildcards
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Claude API] ========== REQUEST START ==========`);
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error(`[Claude API] ERROR: API key not configured`);
return res.status(500).json({ error: "API key not configured" });
}
// Get the path after /api/claude (e.g., /v1/messages)
// Express 5 returns path segments as an array
const pathParam = req.params.path;
const path = "/" + (Array.isArray(pathParam) ? pathParam.join("/") : pathParam || "");
console.log(`[Claude API] Path: ${path}`);
// Check for session-based request
const { sessionId, ...requestBody } = req.body;
let modifiedBody = requestBody;
console.log(`[Claude API] Session ID: ${sessionId || 'none'}`);
console.log(`[Claude API] Model: ${requestBody.model}`);
console.log(`[Claude API] Messages count: ${requestBody.messages?.length || 0}`);
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Claude API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
if (modifiedBody.system) {
const entityContext = buildEntityContext(session.entities);
console.log(`[Claude API] Entity context added (${entityContext.length} chars)`);
modifiedBody.system += entityContext;
}
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && modifiedBody.messages) {
// Filter out any duplicate messages (in case client sent history too)
const currentContent = modifiedBody.messages[modifiedBody.messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
modifiedBody.messages = [...filteredHistory, ...modifiedBody.messages];
console.log(`[Claude API] Merged ${filteredHistory.length} history + ${modifiedBody.messages.length - filteredHistory.length} new = ${modifiedBody.messages.length} total messages`);
}
} else {
console.log(`[Claude API] WARNING: Session ${sessionId} not found`);
}
}
try {
console.log(`[Claude API] Sending request to Anthropic API...`);
const fetchStart = Date.now();
const response = await fetch(`${ANTHROPIC_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(modifiedBody),
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Claude API] Response received in ${fetchDuration}ms, status: ${response.status}`);
console.log(`[Claude API] Parsing response JSON...`);
const data = await response.json();
console.log(`[Claude API] Response parsed. Stop reason: ${data.stop_reason}, content blocks: ${data.content?.length || 0}`);
if (data.error) {
console.error(`[Claude API] API returned error:`, data.error);
}
// If session exists and response is successful, store messages
if (sessionId && response.ok && data.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new (only if it's a string, not tool results)
const userMessage = requestBody.messages?.[requestBody.messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Claude API] Stored user message to session`);
}
// Store the assistant response (text only, not tool use blocks)
const assistantContent = data.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Claude API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Claude API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.status(response.status).json(data);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Claude API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Claude API] Error:`, error);
console.error(`[Claude API] Error message:`, error.message);
console.error(`[Claude API] Error stack:`, error.stack);
res.status(500).json({ error: "Failed to proxy request to Claude API", details: error.message });
}
});
export default router;

26
server/api/index.js Normal file
View File

@ -0,0 +1,26 @@
import { Router } from "express";
import claudeRouter from "./claude.js";
import ollamaRouter from "./ollama.js";
import sessionRouter from "./session.js";
import userRouter from "./user.js";
const router = Router();
// Session management
router.use("/session", sessionRouter);
// User features
router.use("/user", userRouter);
// Claude API proxy
router.use("/claude", claudeRouter);
// Ollama API proxy
router.use("/ollama", ollamaRouter);
// Health check
router.get("/health", (req, res) => {
res.json({ status: "ok" });
});
export default router;

178
server/api/ollama.js Normal file
View File

@ -0,0 +1,178 @@
import { Router } from "express";
import { getSession, addMessage, getConversationForAPI } from "../services/sessionStore.js";
import { getOllamaUrl } from "../services/providerConfig.js";
import {
claudeToolsToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude
} from "../services/toolConverter.js";
const router = Router();
/**
* Build entity context string for the system prompt
*/
function buildEntityContext(entities) {
if (!entities || entities.length === 0) {
return "\n\nThe diagram is currently empty.";
}
const entityList = entities.map(e => {
const shape = e.template?.replace('#', '').replace('-template', '') || 'unknown';
const pos = e.position || { x: 0, y: 0, z: 0 };
return `- ${e.text || '(no label)'} (${shape}, ${e.color || 'unknown'}) at (${pos.x?.toFixed(1)}, ${pos.y?.toFixed(1)}, ${pos.z?.toFixed(1)})`;
}).join('\n');
return `\n\n## Current Diagram State\nThe diagram currently contains ${entities.length} entities:\n${entityList}`;
}
/**
* Handle Ollama chat requests
* Accepts Claude-format requests and converts them to Ollama format
*/
router.post("/*path", async (req, res) => {
const requestStart = Date.now();
console.log(`[Ollama API] ========== REQUEST START ==========`);
const ollamaUrl = getOllamaUrl();
console.log(`[Ollama API] Using Ollama at: ${ollamaUrl}`);
// Extract request body (Claude format)
const { sessionId, model, max_tokens, system, tools, messages } = req.body;
console.log(`[Ollama API] Session ID: ${sessionId || 'none'}`);
console.log(`[Ollama API] Model: ${model}`);
console.log(`[Ollama API] Messages count: ${messages?.length || 0}`);
// Build system prompt with entity context
let systemPrompt = system || '';
if (sessionId) {
const session = getSession(sessionId);
if (session) {
console.log(`[Ollama API] Session found: ${session.entities.length} entities, ${session.conversationHistory.length} messages in history`);
// Inject entity context into system prompt
const entityContext = buildEntityContext(session.entities);
console.log(`[Ollama API] Entity context added (${entityContext.length} chars)`);
systemPrompt += entityContext;
// Get conversation history and merge with current messages
const historyMessages = getConversationForAPI(sessionId);
if (historyMessages.length > 0 && messages) {
const currentContent = messages[messages.length - 1]?.content;
const filteredHistory = historyMessages.filter(msg => msg.content !== currentContent);
messages.unshift(...filteredHistory);
console.log(`[Ollama API] Merged ${filteredHistory.length} history messages`);
}
} else {
console.log(`[Ollama API] WARNING: Session ${sessionId} not found`);
}
}
// Convert to Ollama format
const ollamaMessages = claudeMessagesToOllama(messages || [], systemPrompt);
const ollamaTools = claudeToolsToOllama(tools);
const ollamaRequest = {
model: model,
messages: ollamaMessages,
stream: false,
options: {
num_predict: max_tokens || 1024
}
};
// Only add tools if there are any
if (ollamaTools.length > 0) {
ollamaRequest.tools = ollamaTools;
}
console.log(`[Ollama API] Converted to Ollama format: ${ollamaMessages.length} messages, ${ollamaTools.length} tools`);
try {
console.log(`[Ollama API] Sending request to Ollama...`);
const fetchStart = Date.now();
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(ollamaRequest)
});
const fetchDuration = Date.now() - fetchStart;
console.log(`[Ollama API] Response received in ${fetchDuration}ms, status: ${response.status}`);
if (!response.ok) {
const errorText = await response.text();
console.error(`[Ollama API] Error response:`, errorText);
return res.status(response.status).json({
error: `Ollama API error: ${response.status}`,
details: errorText
});
}
const ollamaData = await response.json();
console.log(`[Ollama API] Response parsed. Done: ${ollamaData.done}, model: ${ollamaData.model}`);
// Convert response back to Claude format
const claudeResponse = ollamaResponseToClaude(ollamaData);
console.log(`[Ollama API] Converted to Claude format. Stop reason: ${claudeResponse.stop_reason}, content blocks: ${claudeResponse.content.length}`);
// Store messages to session if applicable
if (sessionId && claudeResponse.content) {
const session = getSession(sessionId);
if (session) {
// Store the user message if it was new
const userMessage = messages?.[messages.length - 1];
if (userMessage && userMessage.role === 'user' && typeof userMessage.content === 'string') {
addMessage(sessionId, {
role: 'user',
content: userMessage.content
});
console.log(`[Ollama API] Stored user message to session`);
}
// Store the assistant response (text only)
const assistantContent = claudeResponse.content
.filter(c => c.type === 'text')
.map(c => c.text)
.join('\n');
if (assistantContent) {
addMessage(sessionId, {
role: 'assistant',
content: assistantContent
});
console.log(`[Ollama API] Stored assistant response to session (${assistantContent.length} chars)`);
}
}
}
const totalDuration = Date.now() - requestStart;
console.log(`[Ollama API] ========== REQUEST COMPLETE (${totalDuration}ms) ==========`);
res.json(claudeResponse);
} catch (error) {
const totalDuration = Date.now() - requestStart;
console.error(`[Ollama API] ========== REQUEST FAILED (${totalDuration}ms) ==========`);
console.error(`[Ollama API] Error:`, error);
// Check if it's a connection error
if (error.cause?.code === 'ECONNREFUSED') {
return res.status(503).json({
error: "Ollama is not running",
details: `Could not connect to Ollama at ${ollamaUrl}. Make sure Ollama is installed and running.`
});
}
res.status(500).json({
error: "Failed to proxy request to Ollama",
details: error.message
});
}
});
export default router;

144
server/api/session.js Normal file
View File

@ -0,0 +1,144 @@
import { Router } from "express";
import {
createSession,
getSession,
findSessionByDiagram,
syncEntities,
addMessage,
clearHistory,
deleteSession,
getStats
} from "../services/sessionStore.js";
const router = Router();
/**
* GET /api/session/debug/stats
* Get session statistics (for debugging)
* Query params:
* - details=true: Include full entity and conversation data
* NOTE: Must be before /:id routes to avoid matching "debug" as an id
*/
router.get("/debug/stats", (req, res) => {
const includeDetails = req.query.details === 'true';
const stats = getStats(includeDetails);
console.log('[Session Debug] Stats requested:', JSON.stringify(stats, null, 2));
res.json(stats);
});
/**
* POST /api/session/create
* Create a new session or return existing one for a diagram
*/
router.post("/create", (req, res) => {
const { diagramId } = req.body;
if (!diagramId) {
return res.status(400).json({ error: "diagramId is required" });
}
// Check for existing session
let session = findSessionByDiagram(diagramId);
if (session) {
console.log(`[Session] Resuming existing session ${session.id} for diagram ${diagramId} (${session.conversationHistory.length} messages, ${session.entities.length} entities)`);
return res.json({
session,
isNew: false
});
}
// Create new session
session = createSession(diagramId);
console.log(`[Session] Created new session ${session.id} for diagram ${diagramId}`);
res.json({
session,
isNew: true
});
});
/**
* GET /api/session/:id
* Get session details including history
*/
router.get("/:id", (req, res) => {
const session = getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ session });
});
/**
* PUT /api/session/:id/sync
* Sync entities from client to server
*/
router.put("/:id/sync", (req, res) => {
const { entities } = req.body;
if (!entities || !Array.isArray(entities)) {
return res.status(400).json({ error: "entities array is required" });
}
const session = syncEntities(req.params.id, entities);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
console.log(`[Session ${req.params.id}] Synced ${entities.length} entities:`,
entities.map(e => `${e.text || '(no label)'} (${e.template})`).join(', ') || 'none');
res.json({ success: true, entityCount: entities.length });
});
/**
* POST /api/session/:id/message
* Add a message to history (used after successful Claude response)
*/
router.post("/:id/message", (req, res) => {
const { role, content, toolResults } = req.body;
if (!role || !content) {
return res.status(400).json({ error: "role and content are required" });
}
const session = addMessage(req.params.id, { role, content, toolResults });
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true, messageCount: session.conversationHistory.length });
});
/**
* DELETE /api/session/:id/history
* Clear conversation history
*/
router.delete("/:id/history", (req, res) => {
const session = clearHistory(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
});
/**
* DELETE /api/session/:id
* Delete a session entirely
*/
router.delete("/:id", (req, res) => {
const deleted = deleteSession(req.params.id);
if (!deleted) {
return res.status(404).json({ error: "Session not found" });
}
res.json({ success: true });
});
export default router;

155
server/api/user.js Normal file
View File

@ -0,0 +1,155 @@
import { Router } from "express";
const router = Router();
// Feature configurations by tier
const FEATURE_CONFIGS = {
none: {
tier: 'none',
pages: {
examples: 'off',
documentation: 'off',
pricing: 'off',
vrExperience: 'off',
},
features: {
createDiagram: 'off',
createFromTemplate: 'off',
manageDiagrams: 'off',
shareCollaborate: 'off',
privateDesigns: 'off',
encryptedDesigns: 'off',
editData: 'off',
config: 'off',
enterImmersive: 'off',
launchMetaQuest: 'off',
},
limits: {
maxDiagrams: 0,
maxCollaborators: 0,
storageQuotaMB: 0,
},
},
free: {
tier: 'free',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'coming-soon',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'coming-soon',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 6,
maxCollaborators: 0,
storageQuotaMB: 100,
},
},
basic: {
tier: 'basic',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'pro',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: 25,
maxCollaborators: 0,
storageQuotaMB: 500,
},
},
pro: {
tier: 'pro',
pages: {
examples: 'on',
documentation: 'on',
pricing: 'on',
vrExperience: 'on',
},
features: {
createDiagram: 'on',
createFromTemplate: 'on',
manageDiagrams: 'on',
shareCollaborate: 'on',
privateDesigns: 'on',
encryptedDesigns: 'on',
editData: 'on',
config: 'on',
enterImmersive: 'on',
launchMetaQuest: 'on',
},
limits: {
maxDiagrams: -1,
maxCollaborators: -1,
storageQuotaMB: -1,
},
},
};
// Default tier for authenticated users without a specific tier
const DEFAULT_TIER = 'basic';
/**
* GET /api/user/features
* Returns feature configuration for the current user
*
* Query params:
* - tier: Override tier for testing (e.g., ?tier=pro)
*/
router.get("/features", (req, res) => {
// Allow tier override via query param for testing
const tierOverride = req.query.tier;
// TODO: In production, determine tier from JWT token or user database
// For now, use query param override or default to 'basic'
const tier = tierOverride && FEATURE_CONFIGS[tierOverride]
? tierOverride
: DEFAULT_TIER;
const config = FEATURE_CONFIGS[tier];
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
/**
* GET /api/user/features/:tier
* Returns feature configuration for a specific tier (for testing/admin)
*/
router.get("/features/:tier", (req, res) => {
const { tier } = req.params;
const config = FEATURE_CONFIGS[tier];
if (!config) {
return res.status(404).json({ error: `Unknown tier: ${tier}` });
}
console.log(`[User] Returning feature config for tier: ${tier}`);
res.json(config);
});
export default router;

View File

@ -0,0 +1,88 @@
/**
* Database authentication middleware.
* Allows public databases to be accessed without auth.
* Private databases require authentication.
*/
/**
* Middleware to handle database authentication based on path.
*
* Database naming patterns:
* / - Root endpoint, always allowed (server info)
* /local-{dbname} - Should never reach server (client-only), return 404
* /public-{dbname} - No auth required, anyone can read/write
* /private-{dbname} - Auth required
* /{dbname} - Treated as private by default
*/
export function dbAuthMiddleware(req, res, next) {
// Extract the database name (first segment after /pouchdb/)
const pathParts = req.path.split('/').filter(Boolean);
const dbName = pathParts[0] || '';
// Allow root endpoint (server info check)
if (req.path === '/' || req.path === '') {
console.log(`[DB Auth] Root access: ${req.method} ${req.path}`);
return next();
}
// Local databases should never reach the server (they're browser-only)
if (dbName.startsWith('local-')) {
console.log(`[DB Auth] Local database access rejected: ${req.method} ${req.path}`);
return res.status(404).json({
error: 'not_found',
reason: 'Local databases are browser-only and do not sync to server'
});
}
// Check if this is a public database (name starts with 'public-')
const isPublic = dbName.startsWith('public-');
if (isPublic) {
// No auth required for public databases
console.log(`[DB Auth] Public access: ${req.method} ${req.path}`);
return next();
}
// For private databases, check for auth header
const auth = req.headers.authorization;
if (!auth) {
console.log(`[DB Auth] Unauthorized access attempt: ${req.method} ${req.path}`);
return res.status(401).json({
error: 'unauthorized',
reason: 'Authentication required for private databases'
});
}
// Parse Basic auth header
if (auth.startsWith('Basic ')) {
try {
const credentials = Buffer.from(auth.slice(6), 'base64').toString();
const [username, password] = credentials.split(':');
// For now, accept any credentials for private databases
// TODO: Implement proper user verification
req.dbUser = { name: username };
console.log(`[DB Auth] Authenticated: ${username} accessing ${req.path}`);
return next();
} catch (err) {
console.log(`[DB Auth] Invalid auth header: ${err.message}`);
}
}
// TODO: Add JWT/Bearer token support for Auth0 integration
if (auth.startsWith('Bearer ')) {
// For now, accept bearer tokens without verification
// TODO: Verify JWT with Auth0
req.dbUser = { name: 'bearer-user' };
console.log(`[DB Auth] Bearer token access: ${req.path}`);
return next();
}
return res.status(401).json({
error: 'unauthorized',
reason: 'Invalid authentication'
});
}
export default dbAuthMiddleware;

View File

@ -0,0 +1,62 @@
/**
* Database service using express-pouchdb for self-hosted database sync.
* Provides PouchDB HTTP API compatible with client-side PouchDB replication.
*/
import PouchDB from 'pouchdb';
import PouchDBAdapterMemory from 'pouchdb-adapter-memory';
import expressPouchdb from 'express-pouchdb';
import path from 'path';
import { fileURLToPath } from 'url';
// Register memory adapter (works in Node.js without leveldown issues)
PouchDB.plugin(PouchDBAdapterMemory);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Data directory for persistent storage (used for logs)
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '../../data');
// Use memory adapter for now - data persists while server is running
// TODO: Switch to leveldb once version conflicts are resolved
const memPouchDB = PouchDB.defaults({
adapter: 'memory'
});
// Create express-pouchdb middleware
// Using 'minimumForPouchDB' mode for lightweight operation
// Include routes needed for PouchDB replication
const pouchApp = expressPouchdb(memPouchDB, {
mode: 'minimumForPouchDB',
overrideMode: {
include: [
'routes/root', // GET / - server info
'routes/db', // PUT/GET/DELETE /:db
'routes/all-dbs', // GET /_all_dbs
'routes/changes', // GET /:db/_changes
'routes/bulk-docs', // POST /:db/_bulk_docs
'routes/bulk-get', // POST /:db/_bulk_get
'routes/all-docs', // GET /:db/_all_docs
'routes/revs-diff', // POST /:db/_revs_diff
'routes/documents' // GET/PUT/DELETE /:db/:docid
]
},
logPath: path.join(DATA_DIR, 'logs', 'pouchdb.log')
});
console.log(`[Database] Initialized express-pouchdb with data dir: ${DATA_DIR}`);
// Test that PouchDB can create databases
(async () => {
try {
const testDb = new memPouchDB('_test_db');
const info = await testDb.info();
console.log('[Database] Test DB created successfully:', info);
await testDb.destroy();
console.log('[Database] Test DB destroyed');
} catch (err) {
console.error('[Database] Failed to create test database:', err);
}
})();
export { memPouchDB as PouchDB, pouchApp };

View File

@ -0,0 +1,97 @@
/**
* AI Provider Configuration
* Manages configuration for different AI providers (Claude, Ollama)
*/
// Default configuration
const DEFAULT_PROVIDER = 'claude';
// Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues
const DEFAULT_OLLAMA_URL = 'http://127.0.0.1:11434';
/**
* Get the current AI provider
* @returns {string} Provider name ('claude' or 'ollama')
*/
export function getProvider() {
return process.env.AI_PROVIDER || DEFAULT_PROVIDER;
}
/**
* Get Ollama API URL
* @returns {string} Ollama base URL
*/
export function getOllamaUrl() {
return process.env.OLLAMA_URL || DEFAULT_OLLAMA_URL;
}
/**
* Get Anthropic API URL
* @returns {string} Anthropic base URL
*/
export function getAnthropicUrl() {
return process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com';
}
/**
* Get provider configuration for a specific provider
* @param {string} provider - Provider name
* @returns {object} Provider configuration
*/
export function getProviderConfig(provider) {
switch (provider) {
case 'ollama':
return {
name: 'ollama',
baseUrl: getOllamaUrl(),
chatEndpoint: '/api/chat',
requiresAuth: false
};
case 'claude':
default:
return {
name: 'claude',
baseUrl: getAnthropicUrl(),
chatEndpoint: '/v1/messages',
requiresAuth: true,
apiKey: process.env.ANTHROPIC_API_KEY
};
}
}
/**
* Determine provider from model ID
* @param {string} modelId - Model identifier
* @returns {string} Provider name
*/
export function getProviderFromModel(modelId) {
if (!modelId) return getProvider();
// Claude models start with 'claude-'
if (modelId.startsWith('claude-')) {
return 'claude';
}
// Known Ollama models
const ollamaModels = [
'llama', 'mistral', 'qwen', 'codellama', 'phi',
'gemma', 'neural-chat', 'starling', 'orca', 'vicuna',
'deepseek', 'dolphin', 'nous-hermes', 'openhermes'
];
for (const prefix of ollamaModels) {
if (modelId.toLowerCase().startsWith(prefix)) {
return 'ollama';
}
}
// Default to configured provider
return getProvider();
}
export default {
getProvider,
getOllamaUrl,
getAnthropicUrl,
getProviderConfig,
getProviderFromModel
};

View File

@ -0,0 +1,158 @@
/**
* In-memory session store for diagram chat sessions.
* Stores conversation history and entity snapshots.
*/
import { v4 as uuidv4 } from 'uuid';
// Session structure:
// {
// id: string,
// diagramId: string,
// conversationHistory: Array<{role, content, toolResults?, timestamp}>,
// entities: Array<{id, template, text, color, position}>,
// createdAt: Date,
// lastAccess: Date
// }
const sessions = new Map();
// Session timeout (1 hour of inactivity)
const SESSION_TIMEOUT_MS = 60 * 60 * 1000;
/**
* Create a new session for a diagram
*/
export function createSession(diagramId) {
const id = uuidv4();
const session = {
id,
diagramId,
conversationHistory: [],
entities: [],
createdAt: new Date(),
lastAccess: new Date()
};
sessions.set(id, session);
return session;
}
/**
* Get a session by ID
*/
export function getSession(sessionId) {
const session = sessions.get(sessionId);
if (session) {
session.lastAccess = new Date();
}
return session || null;
}
/**
* Find existing session for a diagram
*/
export function findSessionByDiagram(diagramId) {
for (const [, session] of sessions) {
if (session.diagramId === diagramId) {
session.lastAccess = new Date();
return session;
}
}
return null;
}
/**
* Update entities snapshot for a session
*/
export function syncEntities(sessionId, entities) {
const session = sessions.get(sessionId);
if (!session) return null;
session.entities = entities;
session.lastAccess = new Date();
return session;
}
/**
* Add a message to conversation history
*/
export function addMessage(sessionId, message) {
const session = sessions.get(sessionId);
if (!session) return null;
session.conversationHistory.push({
...message,
timestamp: new Date()
});
session.lastAccess = new Date();
return session;
}
/**
* Get conversation history for API calls (formatted for Claude)
*/
export function getConversationForAPI(sessionId) {
const session = sessions.get(sessionId);
if (!session) return [];
// Convert to Claude message format
return session.conversationHistory.map(msg => ({
role: msg.role,
content: msg.content
}));
}
/**
* Clear conversation history but keep session
*/
export function clearHistory(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
session.conversationHistory = [];
session.lastAccess = new Date();
return session;
}
/**
* Delete a session
*/
export function deleteSession(sessionId) {
return sessions.delete(sessionId);
}
/**
* Clean up expired sessions
*/
export function cleanupExpiredSessions() {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastAccess.getTime() > SESSION_TIMEOUT_MS) {
sessions.delete(id);
}
}
}
// Run cleanup every 15 minutes
setInterval(cleanupExpiredSessions, 15 * 60 * 1000);
/**
* Get session stats (for debugging)
*/
export function getStats(includeDetails = false) {
return {
activeSessions: sessions.size,
sessions: Array.from(sessions.values()).map(s => ({
id: s.id,
diagramId: s.diagramId,
messageCount: s.conversationHistory.length,
entityCount: s.entities.length,
lastAccess: s.lastAccess,
// Include full details if requested
...(includeDetails && {
entities: s.entities,
conversationHistory: s.conversationHistory
})
}))
};
}

View File

@ -0,0 +1,245 @@
/**
* Tool Format Converter
* Converts between Claude and Ollama tool/function formats
*/
/**
* Convert Claude tool definition to Ollama function format
*
* Claude format:
* { name: "...", description: "...", input_schema: { type: "object", properties: {...} } }
*
* Ollama format:
* { type: "function", function: { name: "...", description: "...", parameters: {...} } }
*
* @param {object} claudeTool - Tool in Claude format
* @returns {object} Tool in Ollama format
*/
export function claudeToolToOllama(claudeTool) {
return {
type: "function",
function: {
name: claudeTool.name,
description: claudeTool.description,
parameters: claudeTool.input_schema
}
};
}
/**
* Convert array of Claude tools to Ollama format
* @param {Array} claudeTools - Array of Claude tool definitions
* @returns {Array} Array of Ollama function definitions
*/
export function claudeToolsToOllama(claudeTools) {
if (!claudeTools || !Array.isArray(claudeTools)) {
return [];
}
return claudeTools.map(claudeToolToOllama);
}
/**
* Convert Ollama tool call to Claude format
*
* Ollama format (in message):
* { tool_calls: [{ function: { name: "...", arguments: {...} } }] }
*
* Claude format:
* { type: "tool_use", id: "...", name: "...", input: {...} }
*
* @param {object} ollamaToolCall - Tool call from Ollama response
* @param {number} index - Index for generating unique ID
* @returns {object} Tool call in Claude format
*/
export function ollamaToolCallToClaude(ollamaToolCall, index = 0) {
const func = ollamaToolCall.function;
// Parse arguments if it's a string
let input = func.arguments;
if (typeof input === 'string') {
try {
input = JSON.parse(input);
} catch (e) {
console.warn('[ToolConverter] Failed to parse tool arguments:', e);
input = {};
}
}
return {
type: "tool_use",
id: `toolu_ollama_${Date.now()}_${index}`,
name: func.name,
input: input || {}
};
}
/**
* Convert Claude tool result to Ollama format
*
* Claude format (in messages):
* { role: "user", content: [{ type: "tool_result", tool_use_id: "...", content: "..." }] }
*
* Ollama format:
* { role: "tool", content: "...", name: "..." }
*
* @param {object} claudeToolResult - Tool result in Claude format
* @param {string} toolName - Name of the tool (from previous tool_use)
* @returns {object} Tool result in Ollama message format
*/
export function claudeToolResultToOllama(claudeToolResult, toolName) {
let content = claudeToolResult.content;
// Stringify if it's an object
if (typeof content === 'object') {
content = JSON.stringify(content);
}
return {
role: "tool",
content: content,
name: toolName
};
}
/**
* Convert Claude messages array to Ollama format
* Handles regular messages and tool result messages
*
* @param {Array} claudeMessages - Messages in Claude format
* @param {string} systemPrompt - System prompt to prepend
* @returns {Array} Messages in Ollama format
*/
export function claudeMessagesToOllama(claudeMessages, systemPrompt) {
const ollamaMessages = [];
// Add system message if provided
if (systemPrompt) {
ollamaMessages.push({
role: "system",
content: systemPrompt
});
}
// Track tool names for tool results
const toolNameMap = new Map();
for (const msg of claudeMessages) {
if (msg.role === 'user') {
// Check if it's a tool result message
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'tool_result') {
const toolName = toolNameMap.get(block.tool_use_id) || 'unknown';
ollamaMessages.push(claudeToolResultToOllama(block, toolName));
} else if (block.type === 'text') {
ollamaMessages.push({
role: "user",
content: block.text
});
}
}
} else {
ollamaMessages.push({
role: "user",
content: msg.content
});
}
} else if (msg.role === 'assistant') {
// Handle assistant messages with potential tool calls
if (Array.isArray(msg.content)) {
let textContent = '';
const toolCalls = [];
for (const block of msg.content) {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'tool_use') {
// Track tool name for later tool results
toolNameMap.set(block.id, block.name);
toolCalls.push({
function: {
name: block.name,
// Ollama expects arguments as object, not string
arguments: block.input || {}
}
});
}
}
const assistantMsg = {
role: "assistant",
content: textContent || ""
};
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls;
}
ollamaMessages.push(assistantMsg);
} else {
ollamaMessages.push({
role: "assistant",
content: msg.content
});
}
}
}
return ollamaMessages;
}
/**
* Convert Ollama response to Claude format
*
* @param {object} ollamaResponse - Response from Ollama API
* @returns {object} Response in Claude format
*/
export function ollamaResponseToClaude(ollamaResponse) {
const content = [];
const message = ollamaResponse.message;
// Add text content if present
if (message.content) {
content.push({
type: "text",
text: message.content
});
}
// Add tool calls if present
if (message.tool_calls && message.tool_calls.length > 0) {
for (let i = 0; i < message.tool_calls.length; i++) {
content.push(ollamaToolCallToClaude(message.tool_calls[i], i));
}
}
// Determine stop reason
let stopReason = "end_turn";
if (message.tool_calls && message.tool_calls.length > 0) {
stopReason = "tool_use";
} else if (ollamaResponse.done_reason === "length") {
stopReason = "max_tokens";
}
return {
id: `msg_ollama_${Date.now()}`,
type: "message",
role: "assistant",
content: content,
model: ollamaResponse.model,
stop_reason: stopReason,
usage: {
input_tokens: ollamaResponse.prompt_eval_count || 0,
output_tokens: ollamaResponse.eval_count || 0
}
};
}
export default {
claudeToolToOllama,
claudeToolsToOllama,
ollamaToolCallToClaude,
claudeToolResultToOllama,
claudeMessagesToOllama,
ollamaResponseToClaude
};

View File

@ -1,4 +1,4 @@
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, WebXRDefaultExperience} from "@babylonjs/core"; import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
import log from "loglevel"; import log from "loglevel";
@ -13,6 +13,7 @@ import {UserModelType} from "../users/userTypes";
import {vectoxys} from "./functions/vectorConversion"; import {vectoxys} from "./functions/vectorConversion";
import {controllerObservable} from "../controllers/controllers"; import {controllerObservable} from "../controllers/controllers";
import {ControllerEvent} from "../controllers/types/controllerEvent"; import {ControllerEvent} from "../controllers/types/controllerEvent";
import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes";
export class DiagramManager { export class DiagramManager {
@ -107,6 +108,13 @@ export class DiagramManager {
document.addEventListener('chatCreateEntity', (event: CustomEvent) => { document.addEventListener('chatCreateEntity', (event: CustomEvent) => {
const {entity} = event.detail; const {entity} = event.detail;
this._logger.debug('chatCreateEntity', entity); this._logger.debug('chatCreateEntity', entity);
// Generate a default label if none is provided
if (!entity.text) {
entity.text = this.generateDefaultLabel(entity);
this._logger.debug('Generated default label:', entity.text);
}
const object = new DiagramObject(this._scene, this.onDiagramEventObservable, { const object = new DiagramObject(this._scene, this.onDiagramEventObservable, {
diagramEntity: entity, diagramEntity: entity,
actionManager: this._diagramEntityActionManager actionManager: this._diagramEntityActionManager
@ -142,16 +150,61 @@ export class DiagramManager {
if (entity) { if (entity) {
const diagramObject = this._diagramObjects.get(entity.id); const diagramObject = this._diagramObjects.get(entity.id);
if (diagramObject) { if (diagramObject) {
// Apply updates using setters (each setter handles its own DB notification)
if (updates.text !== undefined) { if (updates.text !== undefined) {
diagramObject.text = updates.text; diagramObject.text = updates.text;
} }
// Note: color and position updates would require additional DiagramObject methods if (updates.color !== undefined) {
const updatedEntity = {...entity, ...updates}; diagramObject.color = updates.color;
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: updatedEntity
}, DiagramEventObserverMask.TO_DB);
} }
if (updates.position !== undefined) {
diagramObject.position = updates.position;
}
}
} else {
this._logger.warn('chatModifyEntity: entity not found:', target);
}
});
document.addEventListener('chatModifyConnection', (event: CustomEvent) => {
const {target, updates} = event.detail;
this._logger.debug('chatModifyConnection', target, updates);
let connection: DiagramEntity | undefined;
// Check if target is a connection:fromId:toId format
if (target.startsWith('connection:')) {
const parts = target.split(':');
if (parts.length === 3) {
const fromId = parts[1];
const toId = parts[2];
// Find connection by from/to
connection = Array.from(this._diagramObjects.values())
.map(obj => obj.diagramEntity)
.find(e => e.template === '#connection-template' && e.from === fromId && e.to === toId);
}
} else {
// Find by label (text)
connection = this.findEntityByIdOrLabel(target);
// Verify it's a connection
if (connection && connection.template !== '#connection-template') {
this._logger.warn('chatModifyConnection: found entity is not a connection:', target);
connection = undefined;
}
}
if (connection) {
const diagramObject = this._diagramObjects.get(connection.id);
if (diagramObject) {
if (updates.text !== undefined) {
diagramObject.text = updates.text;
}
if (updates.color !== undefined) {
diagramObject.color = updates.color;
}
}
} else {
this._logger.warn('chatModifyConnection: connection not found:', target);
} }
}); });
@ -161,6 +214,7 @@ export class DiagramManager {
id: obj.diagramEntity.id, id: obj.diagramEntity.id,
template: obj.diagramEntity.template, template: obj.diagramEntity.template,
text: obj.diagramEntity.text || '', text: obj.diagramEntity.text || '',
color: obj.diagramEntity.color,
position: obj.diagramEntity.position position: obj.diagramEntity.position
})); }));
const responseEvent = new CustomEvent('chatListEntitiesResponse', { const responseEvent = new CustomEvent('chatListEntitiesResponse', {
@ -170,6 +224,89 @@ export class DiagramManager {
document.dispatchEvent(responseEvent); document.dispatchEvent(responseEvent);
}); });
// Resolve entity label/ID to actual entity ID and label
document.addEventListener('chatResolveEntity', (event: CustomEvent) => {
const {target, requestId} = event.detail;
this._logger.debug('chatResolveEntity', target);
const entity = this.findEntityByIdOrLabel(target);
const responseEvent = new CustomEvent('chatResolveEntityResponse', {
detail: {
requestId,
target,
entityId: entity?.id || null,
entityLabel: entity?.text || null,
found: !!entity
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
// Clear all entities from the diagram
document.addEventListener('chatClearDiagram', () => {
this._logger.debug('chatClearDiagram - removing all entities');
const entitiesToRemove = Array.from(this._diagramObjects.keys());
for (const id of entitiesToRemove) {
const diagramObject = this._diagramObjects.get(id);
if (diagramObject) {
const entity = diagramObject.diagramEntity;
diagramObject.dispose();
this._diagramObjects.delete(id);
this.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.REMOVE,
entity: entity
}, DiagramEventObserverMask.TO_DB);
}
}
this._logger.debug(`Cleared ${entitiesToRemove.length} entities`);
});
// Get current camera position and orientation
// Camera may be parented to a platform, so we use world-space coordinates
document.addEventListener('chatGetCamera', () => {
this._logger.debug('chatGetCamera');
const camera = this._scene.activeCamera;
if (!camera) {
this._logger.warn('No active camera found');
return;
}
// World-space position (accounts for parent transforms)
const position = camera.globalPosition;
// World-space forward direction (where camera is looking)
const forward = camera.getForwardRay(1).direction;
// World up vector
const worldUp = new Vector3(0, 1, 0);
// Compute ground-projected forward (for intuitive forward/back movement)
// This ignores pitch so looking up/down doesn't affect horizontal movement
const groundForward = new Vector3(forward.x, 0, forward.z);
const groundForwardLength = groundForward.length();
if (groundForwardLength > 0.001) {
groundForward.scaleInPlace(1 / groundForwardLength);
} else {
// Looking straight up/down - use a fallback forward
groundForward.set(0, 0, -1);
}
// Compute right vector (perpendicular to groundForward in XZ plane)
// Right = Cross(groundForward, worldUp) gives left, so we negate or swap
const groundRight = Vector3.Cross(worldUp, groundForward).normalize();
const responseEvent = new CustomEvent('chatGetCameraResponse', {
detail: {
position: {x: position.x, y: position.y, z: position.z},
forward: {x: forward.x, y: forward.y, z: forward.z},
groundForward: {x: groundForward.x, y: groundForward.y, z: groundForward.z},
groundRight: {x: groundRight.x, y: groundRight.y, z: groundRight.z}
},
bubbles: true
});
document.dispatchEvent(responseEvent);
});
this._logger.debug("DiagramManager constructed"); this._logger.debug("DiagramManager constructed");
} }
@ -219,6 +356,42 @@ export class DiagramManager {
return null; return null;
} }
/**
* Generates a default label for an entity based on its color and shape.
* Format: "{color} {shape} {number}" e.g., "blue box 1", "red sphere 2"
* The number is determined by counting existing entities with the same prefix.
*/
private generateDefaultLabel(entity: DiagramEntity): string {
// Get color name from hex
const colorHex = entity.color?.toLowerCase() || '#0000ff';
const colorName = HEX_TO_COLOR_NAME[colorHex] || 'blue';
// Get shape name from template
const shapeName = TEMPLATE_TO_SHAPE[entity.template] || 'box';
// Create the prefix (e.g., "blue box")
const prefix = `${colorName} ${shapeName}`;
// Count existing entities with labels starting with this prefix
let maxNumber = 0;
for (const [, obj] of this._diagramObjects) {
const label = obj.diagramEntity.text?.toLowerCase() || '';
if (label.startsWith(prefix)) {
// Extract the number from the end of the label
const match = label.match(new RegExp(`^${prefix}\\s*(\\d+)$`));
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNumber) {
maxNumber = num;
}
}
}
}
// Return the next number in sequence
return `${prefix} ${maxNumber + 1}`;
}
private onDiagramEvent(event: DiagramEvent) { private onDiagramEvent(event: DiagramEvent) {
let diagramObject = this._diagramObjects.get(event?.entity?.id); let diagramObject = this._diagramObjects.get(event?.entity?.id);
switch (event.type) { switch (event.type) {

View File

@ -142,6 +142,56 @@ export class DiagramObject {
return this._diagramEntity; return this._diagramEntity;
} }
public set position(value: { x: number; y: number; z: number }) {
if (this._baseTransform) {
this._baseTransform.position = new Vector3(value.x, value.y, value.z);
if (this._diagramEntity) {
this._diagramEntity.position = value;
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
}
public set color(value: string) {
if (!this._diagramEntity || this._diagramEntity.color === value) {
return;
}
this._logger.debug('Changing color from', this._diagramEntity.color, 'to', value);
// Update the entity color
this._diagramEntity.color = value;
// Rebuild mesh with new color (since instances share materials)
// Must dispose old mesh FIRST, otherwise buildMeshFromDiagramEntity
// finds it by ID and returns the same mesh (which we then dispose!)
if (this._mesh) {
const actionManager = this._mesh.actionManager;
this._mesh.dispose();
this._mesh = null;
this._mesh = buildMeshFromDiagramEntity(this._diagramEntity, this._scene);
if (this._mesh) {
this._mesh.setParent(this._baseTransform);
this._mesh.position = Vector3.Zero();
this._mesh.rotation = Vector3.Zero();
if (actionManager) {
this._mesh.actionManager = actionManager;
}
} else {
this._logger.error('Failed to rebuild mesh with new color');
}
}
this._eventObservable.notifyObservers({
type: DiagramEventType.MODIFY,
entity: this._diagramEntity
}, DiagramEventObserverMask.TO_DB);
}
public set text(value: string) { public set text(value: string) {
if (this._label) { if (this._label) {
this._label.dispose(); this._label.dispose();
@ -269,30 +319,26 @@ export class DiagramObject {
if (!this._meshRemovedObserver) { if (!this._meshRemovedObserver) {
this._meshRemovedObserver = this._scene.onMeshRemovedObservable.add((mesh) => { this._meshRemovedObserver = this._scene.onMeshRemovedObservable.add((mesh) => {
if (mesh && mesh.id) { if (mesh && mesh.id) {
// When an endpoint mesh is removed, don't immediately dispose the connection.
// Instead, clear the mesh references and reset the timer. The scene observer
// will try to re-find the meshes (handles entity modification where mesh is
// disposed and recreated with same ID). If meshes can't be found after
// timeout, the scene observer will dispose the connection.
switch (mesh.id) { switch (mesh.id) {
case this._from: case this._from:
this._fromMesh = null; this._fromMesh = null;
this._lastFromPosition = null; this._lastFromPosition = null;
this._meshesPresent = false; this._meshesPresent = false;
this._eventObservable.notifyObservers({ this._observingStart = Date.now(); // Reset timeout
type: DiagramEventType.REMOVE,
entity: this._diagramEntity
}, DiagramEventObserverMask.ALL);
this.dispose();
break; break;
case this._to: case this._to:
this._toMesh = null; this._toMesh = null;
this._lastToPosition = null; this._lastToPosition = null;
this._meshesPresent = false; this._meshesPresent = false;
this._eventObservable.notifyObservers({ this._observingStart = Date.now(); // Reset timeout
type: DiagramEventType.REMOVE, break;
entity: this._diagramEntity
}, DiagramEventObserverMask.ALL);
this.dispose();
} }
} }
}, -1, false, this); }, -1, false, this);
} }
if (!this._sceneObserver) { if (!this._sceneObserver) {

View File

@ -5,18 +5,78 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
import log, {Logger} from "loglevel"; import log, {Logger} from "loglevel";
import PouchDB from 'pouchdb'; import PouchDB from 'pouchdb';
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON"; import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
import {getDbType, getRemoteDbPath, shouldSync} from "../../util/functions/getPath";
export class PouchData { export class PouchData {
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>(); public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>(); public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
private _db: PouchDB; private _db: PouchDB;
private _remote: PouchDB;
private _diagramManager: DiagramManager; private _diagramManager: DiagramManager;
private _logger: Logger = log.getLogger('PouchData'); private _logger: Logger = log.getLogger('PouchData');
private _dbName: string; private _dbName: string;
private _syncHandler: any;
constructor(dbname: string) { constructor(dbname: string) {
this._db = new PouchDB(dbname); this._db = new PouchDB(dbname);
this._dbName = dbname; this._dbName = dbname;
// Start sync for public databases
this.initSync();
}
/**
* Initialize sync with remote express-pouchdb for public/private databases
* Local databases do not sync
*/
private initSync() {
const dbType = getDbType();
const remoteDbPath = getRemoteDbPath();
if (dbType === 'local') {
this._logger.debug('[Sync] Local database - no remote sync');
return;
}
if (dbType === 'private') {
this._logger.info('[Sync] Private database sync not yet implemented');
return;
}
if (!remoteDbPath || !shouldSync()) {
this._logger.debug('[Sync] No remote path or sync disabled, skipping');
return;
}
const remoteUrl = `${window.location.origin}/pouchdb/${remoteDbPath}`;
this._logger.info(`[Sync] Connecting to remote: ${remoteUrl}`);
this._remote = new PouchDB(remoteUrl);
// Start live bidirectional sync
this._syncHandler = this._db.sync(this._remote, { live: true, retry: true })
.on('change', (info) => {
this._logger.debug('[Sync] Change:', info.direction, info.change.docs.length, 'docs');
// Process incoming changes
if (info.direction === 'pull' && info.change && info.change.docs) {
info.change.docs.forEach((doc) => {
if (doc._deleted) {
this.onDBEntityRemoveObservable.notifyObservers(doc);
} else if (doc.id && doc.id !== 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(doc);
}
});
}
})
.on('paused', (info) => {
this._logger.debug('[Sync] Paused - up to date');
})
.on('active', () => {
this._logger.debug('[Sync] Active - syncing');
})
.on('error', (err) => {
this._logger.error('[Sync] Error:', err);
});
} }
public setDiagramManager(diagramManager: DiagramManager) { public setDiagramManager(diagramManager: DiagramManager) {
this._diagramManager = diagramManager; this._diagramManager = diagramManager;
@ -137,4 +197,52 @@ export class PouchData {
this._logger.warn('CONFLICTS!', doc._conflicts); this._logger.warn('CONFLICTS!', doc._conflicts);
} }
} }
/**
* Copy all documents from this database to a new public database.
* Used when sharing a local diagram to make it publicly accessible.
* @param newDbName - The name for the new public database
* @returns The URL path to the new public diagram
*/
public async copyToPublic(newDbName: string): Promise<string> {
this._logger.info(`[Copy] Starting copy to public-${newDbName}`);
// Create the remote public database
const remoteUrl = `${window.location.origin}/pouchdb/public-${newDbName}`;
const remoteDb = new PouchDB(remoteUrl);
try {
// Get all docs from local database
const allDocs = await this._db.allDocs({ include_docs: true });
this._logger.debug(`[Copy] Found ${allDocs.rows.length} documents to copy`);
// Copy each document to the remote database
for (const row of allDocs.rows) {
if (row.doc) {
// Remove PouchDB internal fields for clean insert
const { _rev, ...docWithoutRev } = row.doc;
try {
await remoteDb.put(docWithoutRev);
} catch (err) {
// Document might already exist if this is a retry
this._logger.warn(`[Copy] Failed to copy doc ${row.id}:`, err);
}
}
}
this._logger.info(`[Copy] Successfully copied ${allDocs.rows.length} documents`);
return `/db/public/${newDbName}`;
} catch (err) {
this._logger.error('[Copy] Error copying to public:', err);
throw err;
}
}
/**
* Get all documents in the database (for export/copy operations)
*/
public async getAllDocs(): Promise<any[]> {
const result = await this._db.allDocs({ include_docs: true });
return result.rows.map(row => row.doc).filter(doc => doc && doc.id !== 'metadata');
}
} }

View File

@ -1,409 +0,0 @@
import PouchDB from 'pouchdb';
import {DiagramEntity, DiagramEntityType, DiagramEventType} from "../../diagram/types/diagramEntity";
import {Observable} from "@babylonjs/core";
import axios from "axios";
import {DiagramManager} from "../../diagram/diagramManager";
import log, {Logger} from "loglevel";
import {ascii_to_hex} from "../functions/hexFunctions";
import {getPath} from "../../util/functions/getPath";
import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask";
import {syncDoc} from "../functions/syncDoc";
import {checkDb} from "../functions/checkDb";
import {UserModelType} from "../../users/userTypes";
import {getMe} from "../../util/me";
import {Encryption} from "../encryption";
import {Presence} from "../presence";
type PasswordEvent = {
detail: string;
}
type PasswordEvent2 = {
password: string;
id: string;
encrypted: boolean;
}
export class PouchdbPersistenceManager {
private _logger: Logger = log.getLogger('PouchdbPersistenceManager');
onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
private db: PouchDB;
private remote: PouchDB;
private user: string;
private _encryption = new Encryption();
private _encKey = null;
private _diagramManager: DiagramManager;
private _salt: string;
private _failCount: number = 0;
constructor() {
document.addEventListener('passwordset', (evt) => {
this._encKey = ((evt as unknown) as PasswordEvent).detail || null;
if (this._encKey && typeof (this._encKey) == 'string') {
this.initialize().then(() => {
this._logger.debug('Initialized');
});
}
this._logger.debug(evt);
});
document.addEventListener('dbcreated', (evt: CustomEvent) => {
const detail = ((evt.detail as unknown) as PasswordEvent2);
const password = detail.password;
const id = detail.id;
if (detail.encrypted) {
this._encKey = password;
} else {
this._encKey = null;
}
//this._encKey = password;
this.db = new PouchDB(detail.id, {auto_compaction: true});
this.setupMetadata(id).then(() => {
document.location.href = '/db/' + id;
}).catch((err) => {
console.log(err);
})
});
}
public setDiagramManager(diagramManager: DiagramManager) {
this._diagramManager = diagramManager;
diagramManager.onDiagramEventObservable.add((evt) => {
this._logger.debug(evt);
if (!evt?.entity) {
this._logger.warn('no entity');
return;
}
if (!evt?.entity?.id) {
this._logger.warn('no entity id');
return;
}
switch (evt.type) {
case DiagramEventType.REMOVE:
this.remove(evt.entity.id);
break;
case DiagramEventType.ADD:
case DiagramEventType.MODIFY:
case DiagramEventType.DROP:
this.upsert(evt.entity);
break;
default:
this._logger.warn('unknown diagram event type', evt);
}
}, DiagramEventObserverMask.TO_DB);
this.onDBEntityUpdateObservable.add((evt) => {
this._logger.debug(evt);
if (evt.id != 'metadata' && evt?.type == DiagramEntityType.USER) {
diagramManager.onDiagramEventObservable.notifyObservers({
type: DiagramEventType.ADD,
entity: evt
}, DiagramEventObserverMask.FROM_DB);
} else {
}
});
this.onDBEntityRemoveObservable.add((entity) => {
this._logger.debug(entity);
diagramManager.onDiagramEventObservable.notifyObservers(
{type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB);
});
}
public async remove(id: string) {
if (!id) {
return;
}
try {
const doc = await this.db.get(id);
this.db.remove(doc);
} catch (err) {
this._logger.error(err);
}
}
public async upsert(entity: DiagramEntity) {
if (!entity) {
return;
}
if (entity.template == '#image-template' && !entity.image) {
this._logger.error('no image data', entity);
return;
}
if (this._encKey && !this._encryption.ready) {
await this._encryption.setPassword(this._encKey);
}
try {
const doc = await this.db.get(entity.id, {conflicts: true, include_docs: true});
if (doc && doc._conflicts) {
this._logger.warn('CONFLICTS!', doc._conflicts);
}
if (this._encKey) {
if (!doc.encrypted) {
this._logger.warn("current local doc is not encrypted, encrypting");
}
await this._encryption.encryptObject(entity);
const newDoc = {
_id: doc._id,
_rev: doc._rev,
encrypted: this._encryption.getEncrypted()
}
this.db.put(newDoc)
} else {
if (doc) {
if (doc.encrypted) {
this._logger.error("current local doc is encrypted, but encryption key is missing... saving in plaintext");
}
const newDoc = {_id: doc._id, _rev: doc._rev, ...entity};
this.db.put(newDoc);
} else {
this.db.put({_id: entity.id, ...entity});
}
}
} catch (err) {
if (err.status == 404) {
try {
if (this._encKey) {
if (!this._encryption.ready) {
this._logger.error('Encryption not ready, there is a potential problem when this happens, we will generate a new salt which may cause data loss and/or slowness');
await this._encryption.setPassword(this._encKey);
}
await this._encryption.encryptObject(entity);
const newDoc = {
_id: entity.id,
encrypted: this._encryption.getEncrypted()
}
this.db.put(newDoc);
} else {
this._logger.info('no encryption key, saving in plaintext');
const newEntity = {_id: entity.id, ...entity};
this.db.put(newEntity);
}
} catch (err2) {
this._logger.error("Unable to save document");
this._logger.error(err2);
}
} else {
this._logger.error("Unknown error with document get from db");
this._logger.error(err);
}
}
}
public async initialize() {
if (!await this.initLocal()) {
return;
}
await this.sendLocalDataToScene();
}
private async setupMetadata(current: string): Promise<boolean> {
try {
const doc = await this.db.get('metadata');
if (doc.encrypted) {
if (!this._salt && doc.encrypted.salt) {
this._logger.warn('Missing Salt');
this._salt = doc.encrypted.salt;
}
if (!this._encKey) {
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
return false;
}
if (!this._encryption.ready) {
this._logger.warn("Encryption not ready, setting password");
await this._encryption.setPassword(this._encKey, doc.encrypted.salt);
}
const decrypted = await this._encryption.decryptToObject(doc.encrypted.encrypted, doc.encrypted.iv);
if (decrypted.friendly) {
this._logger.info("Storing Document friendly name in local storage, decrypted");
localStorage.setItem(current, decrypted.friendly);
}
} else {
if (doc && doc.friendly) {
this._logger.info("Storing Document friendly name in local storage");
localStorage.setItem(current, doc.friendly);
this._encKey = null;
}
if (doc && doc.camera) {
}
}
} catch (err) {
if (err.status == 404) {
this._logger.debug('no metadata found');
const friendly = localStorage.getItem(current);
if (friendly) {
if (this._encKey) {
if (!this._encryption.ready) {
await this._encryption.setPassword(this._encKey);
}
await this._encryption.encryptObject({friendly: friendly});
await this.db.put({_id: 'metadata', id: 'metadata', encrypted: this._encryption.getEncrypted()})
} else {
this._logger.debug('local friendly name found ', friendly, ' setting metadata');
const newDoc = {_id: 'metadata', id: 'metadata', friendly: friendly};
await this.db.put(newDoc);
}
} else {
this._logger.warn('no friendly name found');
}
}
}
return true;
}
private async initLocal(): Promise<boolean> {
try {
let sync = false;
let current = getPath();
if (current && current != 'localdb') {
sync = true;
} else {
current = 'localdb';
}
this.db = new PouchDB(current, {auto_compaction: true});
if (sync) {
if (await this.setupMetadata(current)) {
await this.beginSync(current);
}
}
return true;
} catch (err) {
this._logger.error(err);
this._logger.error('cannot initialize pouchdb for sync');
return false;
}
}
private async sendLocalDataToScene() {
let salt = null;
const clear = localStorage.getItem('clearLocal');
try {
const all = await this.db.allDocs({include_docs: true});
for (const dbEntity of all.rows) {
this._logger.debug(dbEntity.doc);
if (clear) {
this.remove(dbEntity.id);
} else {
if (dbEntity.doc.encrypted) {
if (!salt || salt != dbEntity.doc.encrypted.salt) {
await this._encryption.setPassword(this._encKey, dbEntity.doc.encrypted.salt);
salt = dbEntity.doc.encrypted.salt;
}
const decrypted = await this._encryption.decryptToObject(dbEntity.doc.encrypted.encrypted, dbEntity.doc.encrypted.iv);
if (decrypted.id != 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(decrypted, DiagramEventObserverMask.FROM_DB);
}
} else {
if (dbEntity.id != 'metadata') {
this.onDBEntityUpdateObservable.notifyObservers(dbEntity.doc, DiagramEventObserverMask.FROM_DB);
}
}
}
if (clear) {
localStorage.removeItem('clearLocal');
}
}
} catch (err) {
switch (err.message) {
case 'WebCrypto_DecryptionFailure: ':
case 'Invalid data type!':
this._failCount++;
if (this._failCount < 5) {
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
} else {
this._logger.error('Too many decryption failures, Ignoring... This may compromise your data security');
window.alert('Too many decryption failures, Ignoring... This may compromise your data security');
}
}
this._logger.error(err);
}
}
private async beginSync(localName: string) {
try {
const userHex = ascii_to_hex(localName);
const remoteDbName = 'userdb-' + userHex;
const remoteUserName = localName;
const password = this._encKey || localName;
if (await checkDb(localName, remoteDbName, password) == false) {
return;
}
const userEndpoint: string = import.meta.env.VITE_USER_ENDPOINT
this._logger.debug(userEndpoint);
this._logger.debug(remoteDbName);
const target = await axios.get(userEndpoint);
if (target.status != 200) {
this._logger.warn(target.statusText);
return;
}
if (target.data && target.data.userCtx) {
if (!target.data.userCtx.name || target.data.userCtx.name != remoteUserName) {
try {
const buildTarget = await axios.post(userEndpoint,
{username: remoteUserName, password: password});
if (buildTarget.status != 200) {
this._logger.error(buildTarget.statusText);
return;
} else {
this.user = buildTarget.data.userCtx;
this._logger.debug(this.user);
}
} catch (err) {
if (err.response && err.response.status == 401) {
this._logger.warn(err);
const promptPassword = new CustomEvent('promptpassword', {detail: 'Please enter password'});
document.dispatchEvent(promptPassword);
}
// } else {
this._logger.error(err);
}
}
}
const remoteEndpoint: string = import.meta.env.VITE_SYNCDB_ENDPOINT;
this._logger.debug(remoteEndpoint + remoteDbName);
this.remote = new PouchDB(remoteEndpoint + remoteDbName,
{auth: {username: remoteUserName, password: password}, skip_setup: true});
const dbInfo = await this.remote.info();
this._logger.debug(dbInfo);
const presence: Presence = new Presence(getMe(), remoteDbName);
this._diagramManager.onUserEventObservable.add((user: UserModelType) => {
//this._logger.debug(user);
presence.sendUser(user);
}, -1, false, this);
this.db.sync(this.remote, {live: true, retry: true})
.on('change', (info) => {
syncDoc(info, this.onDBEntityRemoveObservable, this.onDBEntityUpdateObservable, this._encryption, this._encKey);
})
.on('active', (info) => {
this._logger.debug('sync active', info)
})
.on('paused', (info) => {
this._logger.debug('sync paused', info)
})
.on('error', (err) => {
this._logger.error('sync error', err)
});
} catch (err) {
this._logger.error(err);
}
}
}

View File

@ -0,0 +1,94 @@
import React from "react";
import {Badge, Box, Loader, Stack, Text} from "@mantine/core";
import {IconCheck, IconRobot, IconUser, IconX} from "@tabler/icons-react";
import {ChatMessage as ChatMessageType} from "../types/chatTypes";
interface ChatMessageProps {
message: ChatMessageType;
}
export default function ChatMessage({message}: ChatMessageProps) {
const isUser = message.role === 'user';
if (message.isLoading) {
return (
<Box
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem',
marginBottom: '0.5rem',
}}
>
<IconRobot size={20} style={{opacity: 0.7}}/>
<Loader size="sm" type="dots"/>
</Box>
);
}
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
marginBottom: '0.75rem',
}}
>
<Box
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.5rem',
flexDirection: isUser ? 'row-reverse' : 'row',
maxWidth: '90%',
}}
>
<Box
style={{
width: 28,
height: 28,
borderRadius: '50%',
backgroundColor: isUser ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{isUser ? <IconUser size={16}/> : <IconRobot size={16}/>}
</Box>
<Box
style={{
backgroundColor: isUser ? 'var(--mantine-color-blue-9)' : 'var(--mantine-color-dark-6)',
padding: '0.75rem 1rem',
borderRadius: isUser ? '1rem 1rem 0 1rem' : '1rem 1rem 1rem 0',
}}
>
<Text size="sm" style={{whiteSpace: 'pre-wrap'}}>
{message.content}
</Text>
</Box>
</Box>
{message.toolResults && message.toolResults.length > 0 && (
<Stack gap="xs" style={{marginTop: '0.5rem', marginLeft: '2.25rem'}}>
{message.toolResults.map((result, index) => (
<Badge
key={index}
leftSection={result.success ? <IconCheck size={12}/> : <IconX size={12}/>}
color={result.success ? 'green' : 'red'}
variant="light"
size="sm"
>
{result.message.length > 50
? result.message.substring(0, 50) + '...'
: result.message}
</Badge>
))}
</Stack>
)}
</Box>
);
}

View File

@ -0,0 +1,247 @@
import React, {useEffect, useRef, useState} from "react";
import {ActionIcon, Alert, Box, CloseButton, Group, Paper, ScrollArea, Text, Textarea} from "@mantine/core";
import {IconAlertCircle, IconRobot, IconSend} from "@tabler/icons-react";
import ChatMessage from "./ChatMessage";
import {ChatMessage as ChatMessageType, SessionMessage, ToolResult} from "../types/chatTypes";
import {
createAssistantMessage,
createLoadingMessage,
createOrResumeSession,
createUserMessage,
sendMessage,
syncEntitiesToSession
} from "../services/diagramAI";
import {getEntitiesForSync} from "../services/entityBridge";
import {getPath} from "../../util/functions/getPath";
import log from "loglevel";
import {v4 as uuidv4} from 'uuid';
const logger = log.getLogger('ChatPanel');
interface ChatPanelProps {
width?: number;
onClose?: () => void;
}
const WELCOME_MESSAGE = "Hello! I can help you create and modify 3D diagrams. Try saying things like:\n\n• \"Add a blue box labeled 'Server'\"\n• \"Create a red sphere called 'Database'\"\n• \"Connect Server to Database\"\n• \"What's in my diagram?\"\n\nHow can I help you today?";
/**
* Convert session messages to ChatMessage format
*/
function sessionMessageToChatMessage(msg: SessionMessage): ChatMessageType | null {
// Skip messages with non-string content (e.g., tool result objects)
if (typeof msg.content !== 'string') {
return null;
}
return {
id: uuidv4(),
role: msg.role,
content: msg.content,
timestamp: new Date(msg.timestamp),
toolResults: msg.toolResults
};
}
export default function ChatPanel({width = 400, onClose}: ChatPanelProps) {
const [messages, setMessages] = useState<ChatMessageType[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
const [error, setError] = useState<string | null>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Initialize or resume session on mount
useEffect(() => {
const initSession = async () => {
const diagramId = getPath() || 'default';
logger.info('Initializing session for diagram:', diagramId);
try {
// Create or resume session
const {session, isNew} = await createOrResumeSession(diagramId);
logger.info(`Session ${isNew ? 'created' : 'resumed'}:`, session.id);
// Sync current entities to server
const entities = await getEntitiesForSync();
if (entities.length > 0) {
await syncEntitiesToSession(entities);
logger.info('Synced', entities.length, 'entities to session');
}
// Restore conversation history or show welcome message
if (!isNew && session.conversationHistory && session.conversationHistory.length > 0) {
const restoredMessages = session.conversationHistory
.map(sessionMessageToChatMessage)
.filter((msg): msg is ChatMessageType => msg !== null);
if (restoredMessages.length > 0) {
setMessages(restoredMessages);
logger.info('Restored', restoredMessages.length, 'messages from session');
} else {
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
}
} else {
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
}
} catch (err) {
logger.error('Failed to initialize session:', err);
// Fall back to welcome message
setMessages([createAssistantMessage(WELCOME_MESSAGE)]);
} finally {
setIsInitializing(false);
}
};
initSession();
}, []);
// Auto-scroll when messages change
useEffect(() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTo({
top: scrollAreaRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [messages]);
const handleSend = async () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput || isLoading) return;
setError(null);
const userMessage = createUserMessage(trimmedInput);
setMessages(prev => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
const loadingMessage = createLoadingMessage();
setMessages(prev => [...prev, loadingMessage]);
try {
// Sync current entities before sending message so Claude has latest state
const entities = await getEntitiesForSync();
if (entities.length > 0) {
await syncEntitiesToSession(entities);
logger.debug('Synced', entities.length, 'entities before message');
}
const allToolResults: ToolResult[] = [];
const {response, toolResults} = await sendMessage(
trimmedInput,
messages,
(result) => {
allToolResults.push(result);
logger.debug('Tool executed:', result);
}
);
setMessages(prev => {
const filtered = prev.filter(m => m.id !== loadingMessage.id);
return [...filtered, createAssistantMessage(response, toolResults)];
});
} catch (err) {
logger.error('Chat error:', err);
setMessages(prev => prev.filter(m => m.id !== loadingMessage.id));
setError(err instanceof Error ? err.message : 'Failed to send message');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<Paper
shadow="xl"
style={{
width,
height: '100vh',
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid var(--mantine-color-dark-4)',
backgroundColor: 'var(--mantine-color-dark-7)',
}}
>
{/* Header */}
<Box
style={{
padding: '1rem',
borderBottom: '1px solid var(--mantine-color-dark-4)',
flexShrink: 0,
}}
>
<Group justify="space-between">
<Group gap="xs">
<IconRobot size={24}/>
<Text fw={600}>Diagram Assistant</Text>
</Group>
{onClose && <CloseButton onClick={onClose}/>}
</Group>
</Box>
{/* Messages */}
<ScrollArea
style={{flex: 1}}
viewportRef={scrollAreaRef}
p="md"
>
{messages.map((message) => (
<ChatMessage key={message.id} message={message}/>
))}
</ScrollArea>
{/* Error Alert */}
{error && (
<Alert
color="red"
icon={<IconAlertCircle size={16}/>}
withCloseButton
onClose={() => setError(null)}
style={{margin: '0 1rem'}}
>
{error}
</Alert>
)}
{/* Input */}
<Box
style={{
padding: '1rem',
borderTop: '1px solid var(--mantine-color-dark-4)',
flexShrink: 0,
}}
>
<Group gap="xs" align="flex-end">
<Textarea
ref={textareaRef}
placeholder={isInitializing ? "Connecting..." : "Describe what you want to create..."}
value={inputValue}
onChange={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={handleKeyDown}
autosize
minRows={1}
maxRows={4}
disabled={isLoading || isInitializing}
style={{flex: 1}}
/>
<ActionIcon
size="lg"
variant="filled"
onClick={handleSend}
disabled={!inputValue.trim() || isLoading || isInitializing}
loading={isLoading || isInitializing}
>
<IconSend size={18}/>
</ActionIcon>
</Group>
</Box>
</Paper>
);
}

View File

@ -7,11 +7,13 @@ import {useFeatureState} from "../hooks/useFeatures";
import ComingSoonBadge from "../components/ComingSoonBadge"; import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge"; import UpgradeBadge from "../components/UpgradeBadge";
import {useAuth0} from "@auth0/auth0-react"; import {useAuth0} from "@auth0/auth0-react";
import {useNavigate} from "react-router-dom";
export default function CreateDiagramModal({createOpened, closeCreate}) { export default function CreateDiagramModal({createOpened, closeCreate}) {
const logger = log.getLogger('createDiagramModal'); const logger = log.getLogger('createDiagramModal');
const db = usePouch(); const db = usePouch();
const { loginWithRedirect } = useAuth0(); const { loginWithRedirect } = useAuth0();
const navigate = useNavigate();
// Feature flags // Feature flags
const privateDesignsState = useFeatureState('privateDesigns'); const privateDesignsState = useFeatureState('privateDesigns');
@ -43,7 +45,14 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
logger.warn('cannot find directory', err); logger.warn('cannot find directory', err);
} }
const id = 'diagram-' + v4(); const id = 'diagram-' + v4();
const newDiagram = {...diagram, _id: id, type: 'diagram'}; // All new diagrams start as local (browser-only)
const newDiagram = {
...diagram,
_id: id,
type: 'diagram',
storageType: 'local',
createdAt: new Date().toISOString()
};
if (!doc) { if (!doc) {
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'}); await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
} else { } else {
@ -56,6 +65,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
await db.put(doc); await db.put(doc);
} }
closeCreate(); closeCreate();
// Navigate to the local diagram
navigate(`/db/local/${id}`);
} }
return ( return (

View File

@ -1,4 +1,4 @@
import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core"; import {Badge, Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
import React from "react"; import React from "react";
import {useDoc, usePouch} from "use-pouchdb"; import {useDoc, usePouch} from "use-pouchdb";
import {IconTrash, IconDownload} from "@tabler/icons-react"; import {IconTrash, IconDownload} from "@tabler/icons-react";
@ -7,6 +7,26 @@ import log from "loglevel";
import {useFeatureLimit} from "../hooks/useFeatures"; import {useFeatureLimit} from "../hooks/useFeatures";
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON"; import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
// Helper to get the correct URL path based on storage type
function getDiagramPath(diagram: any): string {
const storageType = diagram.storageType || 'public'; // Default to public for backwards compat
return `/db/${storageType}/${diagram._id}`;
}
// Helper to get badge color and text for storage type
function getStorageTypeBadge(storageType: string): { color: string; label: string } {
switch (storageType) {
case 'local':
return { color: 'gray', label: 'Local' };
case 'public':
return { color: 'green', label: 'Public' };
case 'private':
return { color: 'blue', label: 'Private' };
default:
return { color: 'green', label: 'Public' }; // Default for backwards compat
}
}
export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) { export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) {
const logger = log.getLogger('manageDiagramsModal'); const logger = log.getLogger('manageDiagramsModal');
const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []}); const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []});
@ -34,10 +54,16 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
}; };
const cards = diagrams.map((diagram) => { const cards = diagrams.map((diagram) => {
const badgeInfo = getStorageTypeBadge(diagram.storageType);
const diagramPath = getDiagramPath(diagram);
return ( return (
<Card key={diagram._id}> <Card key={diagram._id}>
<Card.Section> <Card.Section>
<Container w={512} h={64}>{diagram.name}</Container> <Group justify="space-between" p="xs">
<Container w={400} h={64}>{diagram.name}</Container>
<Badge color={badgeInfo.color} size="sm">{badgeInfo.label}</Badge>
</Group>
</Card.Section> </Card.Section>
<Card.Section> <Card.Section>
<Container w={512} h={128}> <Container w={512} h={128}>
@ -46,7 +72,7 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
</Card.Section> </Card.Section>
<Card.Section> <Card.Section>
<Group justify="space-evenly"> <Group justify="space-evenly">
<Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor" <Button component={Link} key="examples" to={diagramPath} p={5} c="myColor"
bg="none">Select</Button> bg="none">Select</Button>
<Button <Button

View File

@ -20,6 +20,9 @@ import VREntryPrompt from "../components/VREntryPrompt";
import ComingSoonBadge from "../components/ComingSoonBadge"; import ComingSoonBadge from "../components/ComingSoonBadge";
import UpgradeBadge from "../components/UpgradeBadge"; import UpgradeBadge from "../components/UpgradeBadge";
import ChatPanel from "../components/ChatPanel"; import ChatPanel from "../components/ChatPanel";
import {getDbType} from "../../util/functions/getPath";
import PouchDB from 'pouchdb';
import {v4} from "uuid";
let vrApp: VrApp = null; let vrApp: VrApp = null;
@ -40,6 +43,7 @@ export default function VrExperience() {
const createFromTemplateState = useFeatureState('createFromTemplate'); const createFromTemplateState = useFeatureState('createFromTemplate');
const manageDiagramsState = useFeatureState('manageDiagrams'); const manageDiagramsState = useFeatureState('manageDiagrams');
const shareCollaborateState = useFeatureState('shareCollaborate'); const shareCollaborateState = useFeatureState('shareCollaborate');
console.log('[Share] shareCollaborateState:', shareCollaborateState);
const editDataState = useFeatureState('editData'); const editDataState = useFeatureState('editData');
const configState = useFeatureState('config'); const configState = useFeatureState('config');
const enterImmersiveState = useFeatureState('enterImmersive'); const enterImmersiveState = useFeatureState('enterImmersive');
@ -121,6 +125,96 @@ export default function VrExperience() {
const [showVRPrompt, setShowVRPrompt] = useState(false); const [showVRPrompt, setShowVRPrompt] = useState(false);
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
// Handle share based on current database type:
// - Public: copy current URL to clipboard
// - Local: create one-time copy to public, share new URL
// - Private: show "not yet supported" message
const handleShare = async () => {
const dbType = getDbType();
logger.info(`[Share] Sharing diagram - type: ${dbType}, dbName: ${dbName}`);
if (dbType === 'private') {
alert('Sharing private diagrams is not yet supported.\n\nPrivate diagram sharing with access control is coming soon!');
return;
}
if (dbType === 'local') {
// Create a one-time copy to public
logger.info('[Share] Creating public copy of local diagram...');
try {
// Generate new ID for the public copy
const publicDbName = 'diagram-' + v4();
const localDb = new PouchDB(dbName);
const remoteUrl = `${window.location.origin}/pouchdb/public-${publicDbName}`;
const remoteDb = new PouchDB(remoteUrl);
// Get all docs from local database
const allDocs = await localDb.allDocs({ include_docs: true });
logger.debug(`[Share] Found ${allDocs.rows.length} documents to copy`);
// Copy each document to the remote database
for (const row of allDocs.rows) {
if (row.doc) {
// Remove PouchDB internal fields for clean insert
const { _rev, ...docWithoutRev } = row.doc as any;
try {
await remoteDb.put(docWithoutRev);
} catch (err) {
logger.warn(`[Share] Failed to copy doc ${row.id}:`, err);
}
}
}
const publicUrl = `${window.location.origin}/db/public/${publicDbName}`;
logger.info(`[Share] Public copy created at: ${publicUrl}`);
// Copy URL to clipboard
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(publicUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`Public copy created!\n\nURL copied to clipboard:\n${publicUrl}\n\nNote: Your local diagram remains unchanged. The public copy will diverge independently.`);
} else {
prompt('Public copy created! Share URL (copy manually):', publicUrl);
}
} catch (err) {
logger.error('[Share] Failed to create public copy:', err);
alert('Failed to create public copy. Please try again.');
}
return;
}
// Public diagram - just copy current URL
const shareUrl = window.location.href;
logger.info(`[Share] Sharing public URL: ${shareUrl}`);
// Try to copy URL to clipboard with fallback
let copied = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
copied = true;
}
} catch (clipboardError) {
logger.warn('Clipboard API failed:', clipboardError);
}
if (copied) {
alert(`URL copied to clipboard!\n\n${shareUrl}`);
} else {
// Fallback: show URL in prompt so user can copy manually
prompt('Share URL (copy manually):', shareUrl);
}
};
useEffect(() => { useEffect(() => {
const canvas = document.getElementById('vrCanvas'); const canvas = document.getElementById('vrCanvas');
if (!canvas) { if (!canvas) {
@ -131,6 +225,7 @@ export default function VrExperience() {
logger.debug('destroying vrApp'); logger.debug('destroying vrApp');
vrApp.dispose(); vrApp.dispose();
} }
console.log('[Share] Initializing VrApp with dbName:', dbName);
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName); vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
closeManage(); closeManage();
@ -222,7 +317,7 @@ export default function VrExperience() {
<VrTemplate> <VrTemplate>
{/* Guest Mode Banner - Non-aggressive, dismissible (hidden for demo) */} {/* Guest Mode Banner - Non-aggressive, dismissible (hidden for demo) */}
{!isAuthenticated && !guestBannerDismissed && dbName !== 'demo' && ( {!isAuthenticated && !guestBannerDismissed && dbName !== 'demo' && (
<Affix position={{top: 20, right: 20}} style={{maxWidth: 400}}> <Affix position={{top: 20, right: 20}} style={{maxWidth: 400}} zIndex={100}>
<Alert <Alert
variant="light" variant="light"
color="blue" color="blue"
@ -248,8 +343,8 @@ export default function VrExperience() {
<ConfigModal closeConfig={closeConfig} configOpened={configOpened}/> <ConfigModal closeConfig={closeConfig} configOpened={configOpened}/>
{createModal()} {createModal()}
{manageModal()} {manageModal()}
<Affix position={{top: 30, left: 60}}> <Affix position={{top: 30, left: 60}} zIndex={100}>
<Menu opened={menuOpened} onChange={setMenuOpened}> <Menu opened={menuOpened} onChange={setMenuOpened} position="bottom-start" zIndex={100}>
<Menu.Target> <Menu.Target>
<Burger opened={menuOpened} onClick={toggleMenu} size="xl"/> <Burger opened={menuOpened} onClick={toggleMenu} size="xl"/>
</Menu.Target> </Menu.Target>
@ -329,9 +424,9 @@ export default function VrExperience() {
{shouldShow(shareCollaborateState) && ( {shouldShow(shareCollaborateState) && (
<VrMenuItem <VrMenuItem
tip="Share your model with others and collaborate in real time with others. This is a paid feature." tip="Share your model with others. Creates a shareable link that copies to clipboard."
label="Share" label="Share"
onClick={getClickHandler(shareCollaborateState, null)} onClick={getClickHandler(shareCollaborateState, handleShare)}
availableIcon={getFeatureIndicator(shareCollaborateState)}/> availableIcon={getFeatureIndicator(shareCollaborateState)}/>
)} )}
@ -361,7 +456,7 @@ export default function VrExperience() {
left: 0, left: 0,
width: '100%', width: '100%',
height: '100%', height: '100%',
zIndex: 1000 zIndex: 1
}}/> }}/>
</div> </div>
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)}/>} {chatOpen && <ChatPanel onClose={() => setChatOpen(false)}/>}

View File

@ -0,0 +1,652 @@
import {ChatMessage, CreateSessionResponse, DiagramSession, DiagramToolCall, SessionEntity, SyncEntitiesResponse, ToolResult} from "../types/chatTypes";
import {clearDiagram, connectEntities, createEntity, getCameraPosition, listEntities, modifyConnection, modifyEntity, removeEntity} from "./entityBridge";
import {v4 as uuidv4} from 'uuid';
import log from 'loglevel';
const logger = log.getLogger('diagramAI');
logger.setLevel('debug');
// Session management
let currentSessionId: string | null = null;
// Model management
export type AIProvider = 'claude' | 'ollama';
export interface ModelInfo {
id: string;
name: string;
description: string;
provider: AIProvider;
}
const AVAILABLE_MODELS: ModelInfo[] = [
// Claude models
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',
description: 'Balanced performance and speed (default)',
provider: 'claude'
},
{
id: 'claude-opus-4-20250514',
name: 'Claude Opus 4',
description: 'Most capable, best for complex tasks',
provider: 'claude'
},
{
id: 'claude-haiku-3-5-20241022',
name: 'Claude Haiku 3.5',
description: 'Fastest responses, good for simple tasks',
provider: 'claude'
},
// Ollama models (local)
{
id: 'llama3.1',
name: 'Llama 3.1',
description: 'Local model with function calling support',
provider: 'ollama'
},
{
id: 'mistral',
name: 'Mistral',
description: 'Fast local model with good tool support',
provider: 'ollama'
},
{
id: 'qwen2.5',
name: 'Qwen 2.5',
description: 'Capable local model with function calling',
provider: 'ollama'
}
];
let currentModelId: string = 'claude-sonnet-4-20250514';
/**
* Get the API endpoint for the current model's provider
*/
function getApiEndpoint(): string {
const model = getCurrentModel();
if (model.provider === 'ollama') {
return '/api/ollama/v1/messages';
}
return '/api/claude/v1/messages';
}
/**
* Get available models
*/
export function getAvailableModels(): ModelInfo[] {
return [...AVAILABLE_MODELS];
}
/**
* Get current model
*/
export function getCurrentModel(): ModelInfo {
return AVAILABLE_MODELS.find(m => m.id === currentModelId) || AVAILABLE_MODELS[0];
}
/**
* Set current model
*/
export function setCurrentModel(modelId: string): boolean {
const model = AVAILABLE_MODELS.find(m => m.id === modelId);
if (model) {
currentModelId = modelId;
logger.info('Model changed to:', model.name);
return true;
}
logger.warn('Invalid model ID:', modelId);
return false;
}
/**
* Create a new session or resume existing one for a diagram
*/
export async function createOrResumeSession(diagramId: string): Promise<{ session: DiagramSession; isNew: boolean }> {
const response = await fetch('/api/session/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ diagramId })
});
if (!response.ok) {
throw new Error(`Failed to create session: ${response.status}`);
}
const data: CreateSessionResponse = await response.json();
currentSessionId = data.session.id;
return data;
}
/**
* Get current session ID
*/
export function getCurrentSessionId(): string | null {
return currentSessionId;
}
/**
* Sync entities to the current session
*/
export async function syncEntitiesToSession(entities: SessionEntity[]): Promise<void> {
if (!currentSessionId) {
console.warn('No active session to sync entities to');
return;
}
const response = await fetch(`/api/session/${currentSessionId}/sync`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entities })
});
if (!response.ok) {
console.error('Failed to sync entities:', response.status);
}
}
/**
* Get session with conversation history
*/
export async function getSessionHistory(): Promise<DiagramSession | null> {
if (!currentSessionId) {
return null;
}
const response = await fetch(`/api/session/${currentSessionId}`);
if (!response.ok) {
return null;
}
const data = await response.json();
return data.session;
}
/**
* Clear session history
*/
export async function clearSessionHistory(): Promise<void> {
if (!currentSessionId) {
return;
}
await fetch(`/api/session/${currentSessionId}/history`, {
method: 'DELETE'
});
}
const SYSTEM_PROMPT = `You are a 3D diagram assistant helping users create and modify diagrams in a virtual reality environment.
Available entity shapes: box, sphere, cylinder, cone, plane, person
Available colors: red, blue, green, yellow, orange, purple, cyan, pink, white, black, brown, gray (or any hex color like #ff5500)
IMPORTANT - Handling directional commands (left, right, forward, back, in front of me, etc.):
When the user uses relative directions, you MUST first call get_camera_position to get their current view.
The response provides:
- Their world position
- Ground-projected forward/right vectors (accounts for camera being on a moving platform)
- Pre-calculated positions for forward/back/left/right at 2m distance
Use the ground-plane directions for intuitive placement - these ignore vertical look angle so "forward" means "forward on the ground" not "where I'm looking vertically".
Position coordinates (when not using relative directions):
- x: world left/right
- y: up/down (1.5 is eye level, 0 is floor)
- z: world forward/backward
When creating diagrams, think about good spatial layout:
- Spread entities apart to avoid overlap (at least 0.5 units)
- Use y=1.5 for entities at eye level
- For relative placement, use get_camera_position first
Always use the provided tools to create, modify, or interact with entities. Be concise in your responses.`;
const TOOLS = [
{
name: "create_entity",
description: "Create a 3D shape in the diagram. Use this to add new elements like boxes, spheres, cylinders, etc.",
input_schema: {
type: "object",
properties: {
shape: {
type: "string",
enum: ["box", "sphere", "cylinder", "cone", "plane", "person"],
description: "The type of 3D shape to create"
},
color: {
type: "string",
description: "Color name (red, blue, green, etc.) or hex code (#ff0000)"
},
label: {
type: "string",
description: "Text label to display on or near the entity"
},
position: {
type: "object",
properties: {
x: {type: "number", description: "Left/right position"},
y: {type: "number", description: "Up/down position (1.5 = eye level)"},
z: {type: "number", description: "Forward/backward position"}
},
description: "3D position. If not specified, defaults to (0, 1.5, 2)"
}
},
required: ["shape"]
}
},
{
name: "connect_entities",
description: "Draw a connection line between two entities. Use entity IDs or labels to identify them.",
input_schema: {
type: "object",
properties: {
from: {
type: "string",
description: "ID or label of the source entity"
},
to: {
type: "string",
description: "ID or label of the target entity"
},
color: {
type: "string",
description: "Color of the connection line"
}
},
required: ["from", "to"]
}
},
{
name: "list_entities",
description: "List all entities currently in the diagram. Use this to see what exists before connecting or modifying.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "remove_entity",
description: "Remove an entity from the diagram by its ID or label.",
input_schema: {
type: "object",
properties: {
target: {
type: "string",
description: "ID or label of the entity to remove"
}
},
required: ["target"]
}
},
{
name: "modify_entity",
description: "Modify an existing entity's properties like color, label, or position.",
input_schema: {
type: "object",
properties: {
target: {
type: "string",
description: "ID or label of the entity to modify"
},
color: {
type: "string",
description: "New color for the entity"
},
label: {
type: "string",
description: "New label text. Use empty string \"\" to remove the label."
},
position: {
type: "object",
properties: {
x: {type: "number"},
y: {type: "number"},
z: {type: "number"}
}
}
},
required: ["target"]
}
},
{
name: "modify_connection",
description: "Modify a connection's label or color. Connections can be identified by their label (e.g., 'Server to Database') or by specifying the from/to entities.",
input_schema: {
type: "object",
properties: {
target: {
type: "string",
description: "Label of the connection to modify, or use from/to to identify it"
},
from: {
type: "string",
description: "ID or label of the source entity (alternative to target)"
},
to: {
type: "string",
description: "ID or label of the destination entity (alternative to target)"
},
label: {
type: "string",
description: "New label text for the connection. Use empty string \"\" to remove the label."
},
color: {
type: "string",
description: "New color for the connection"
}
}
}
},
{
name: "clear_diagram",
description: "DESTRUCTIVE: Permanently delete ALL entities from the diagram and clear the session. This cannot be undone. IMPORTANT: Before calling this tool, you MUST first ask the user to confirm by saying something like 'Are you sure you want to clear the entire diagram? This will permanently delete all entities and cannot be undone.' Only call this tool with confirmed=true AFTER the user explicitly confirms (e.g., says 'yes', 'confirm', 'do it', etc.).",
input_schema: {
type: "object",
properties: {
confirmed: {
type: "boolean",
description: "Must be true to execute. Only set to true after user has explicitly confirmed the deletion."
}
},
required: ["confirmed"]
}
},
{
name: "get_camera_position",
description: "Get the current camera/viewer position and orientation in the 3D scene. Use this to understand where the user is looking and to position new entities relative to their view. Returns position (x, y, z) and forward direction vector.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "list_models",
description: "List all available AI models that can be used for this conversation.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "get_current_model",
description: "Get information about the currently active AI model.",
input_schema: {
type: "object",
properties: {}
}
},
{
name: "set_model",
description: "Change the AI model used for this conversation. Use list_models first to see available options.",
input_schema: {
type: "object",
properties: {
model_id: {
type: "string",
description: "The model ID to switch to. Claude models: 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-haiku-3-5-20241022'. Ollama models (local): 'llama3.1', 'mistral', 'qwen2.5'"
}
},
required: ["model_id"]
}
}
];
interface ClaudeMessage {
role: 'user' | 'assistant';
content: string | ClaudeContentBlock[];
}
interface ClaudeContentBlock {
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string;
}
interface ClaudeResponse {
content: ClaudeContentBlock[];
stop_reason: 'end_turn' | 'tool_use' | 'max_tokens';
}
async function executeToolCall(toolCall: DiagramToolCall): Promise<ToolResult> {
logger.debug('[executeToolCall] Executing:', toolCall.name, toolCall.input);
let result: ToolResult;
switch (toolCall.name) {
case 'create_entity':
result = createEntity(toolCall.input);
break;
case 'connect_entities':
result = await connectEntities(toolCall.input);
break;
case 'remove_entity':
result = removeEntity(toolCall.input);
break;
case 'modify_entity':
result = modifyEntity(toolCall.input);
break;
case 'modify_connection':
result = await modifyConnection(toolCall.input);
break;
case 'list_entities':
result = await listEntities();
break;
case 'clear_diagram':
result = await clearDiagram(toolCall.input);
// If successful, also clear the session (history and entities)
if (result.success && currentSessionId) {
await clearSessionHistory();
// Sync empty entity list to clear server-side entity cache
await syncEntitiesToSession([]);
}
break;
case 'get_camera_position':
result = await getCameraPosition();
break;
case 'list_models': {
const models = getAvailableModels();
const current = getCurrentModel();
const modelList = models.map(m =>
`${m.name} (${m.id})${m.id === current.id ? ' [CURRENT]' : ''}\n ${m.description}`
).join('\n\n');
result = {
toolName: 'list_models',
success: true,
message: `Available models:\n\n${modelList}`
};
break;
}
case 'get_current_model': {
const model = getCurrentModel();
result = {
toolName: 'get_current_model',
success: true,
message: `Current model: ${model.name} (${model.id})\n${model.description}`
};
break;
}
case 'set_model': {
const success = setCurrentModel(toolCall.input.model_id);
if (success) {
const model = getCurrentModel();
result = {
toolName: 'set_model',
success: true,
message: `Model changed to: ${model.name}\n${model.description}\n\nNote: The new model will be used starting from the next message.`
};
} else {
const models = getAvailableModels();
result = {
toolName: 'set_model',
success: false,
message: `Invalid model ID: "${toolCall.input.model_id}"\n\nAvailable models: ${models.map(m => m.id).join(', ')}`
};
}
break;
}
default:
result = {
toolName: 'unknown',
success: false,
message: 'Unknown tool'
};
}
logger.debug('[executeToolCall] Result:', result);
return result;
}
export async function sendMessage(
userMessage: string,
conversationHistory: ChatMessage[],
onToolResult?: (result: ToolResult) => void
): Promise<{ response: string; toolResults: ToolResult[] }> {
logger.debug('[sendMessage] Starting with message:', userMessage);
logger.debug('[sendMessage] Session ID:', currentSessionId);
// When using sessions, we don't need to send full history - server manages it
// Just send the new message
const messages: ClaudeMessage[] = currentSessionId
? [{ role: 'user', content: userMessage }]
: conversationHistory
.filter(m => !m.isLoading)
.map(m => ({
role: m.role,
content: m.content
}));
if (!currentSessionId) {
messages.push({role: 'user', content: userMessage});
}
logger.debug('[sendMessage] Messages to send:', messages.length);
const allToolResults: ToolResult[] = [];
let finalResponse = '';
let continueLoop = true;
let loopCount = 0;
while (continueLoop) {
loopCount++;
logger.debug(`[sendMessage] Loop iteration ${loopCount}`);
const requestBody = {
model: currentModelId,
max_tokens: 1024,
system: SYSTEM_PROMPT,
tools: TOOLS,
messages,
...(currentSessionId && { sessionId: currentSessionId })
};
logger.debug('[sendMessage] Request body:', JSON.stringify(requestBody, null, 2).substring(0, 500) + '...');
const apiEndpoint = getApiEndpoint();
logger.debug('[sendMessage] Using API endpoint:', apiEndpoint);
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
logger.debug('[sendMessage] Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
logger.error('[sendMessage] API error:', response.status, errorText);
throw new Error(`API error: ${response.status} - ${errorText}`);
}
const data: ClaudeResponse = await response.json();
logger.debug('[sendMessage] Response data:', JSON.stringify(data, null, 2).substring(0, 500) + '...');
logger.debug('[sendMessage] Stop reason:', data.stop_reason);
const textBlocks = data.content.filter(b => b.type === 'text');
const toolBlocks = data.content.filter(b => b.type === 'tool_use');
logger.debug('[sendMessage] Text blocks:', textBlocks.length, 'Tool blocks:', toolBlocks.length);
if (textBlocks.length > 0) {
finalResponse = textBlocks.map(b => b.text).join('\n');
logger.debug('[sendMessage] Final response:', finalResponse.substring(0, 200) + '...');
}
if (data.stop_reason === 'tool_use' && toolBlocks.length > 0) {
logger.debug('[sendMessage] Processing tool calls...');
messages.push({
role: 'assistant',
content: data.content
});
const toolResults: ClaudeContentBlock[] = [];
for (const toolBlock of toolBlocks) {
logger.debug('[sendMessage] Tool call:', toolBlock.name, JSON.stringify(toolBlock.input));
const toolCall: DiagramToolCall = {
name: toolBlock.name as DiagramToolCall['name'],
input: toolBlock.input as DiagramToolCall['input']
};
const result = await executeToolCall(toolCall);
logger.debug('[sendMessage] Tool result:', result);
allToolResults.push(result);
onToolResult?.(result);
toolResults.push({
type: 'tool_result',
tool_use_id: toolBlock.id,
content: result.message
});
}
messages.push({
role: 'user',
content: toolResults
});
logger.debug('[sendMessage] Added tool results, continuing loop...');
} else {
logger.debug('[sendMessage] No more tool calls, ending loop');
continueLoop = false;
}
}
logger.debug('[sendMessage] Complete. Final response length:', finalResponse.length, 'Tool results:', allToolResults.length);
return {response: finalResponse, toolResults: allToolResults};
}
export function createUserMessage(content: string): ChatMessage {
return {
id: uuidv4(),
role: 'user',
content,
timestamp: new Date()
};
}
export function createAssistantMessage(content: string, toolResults?: ToolResult[]): ChatMessage {
return {
id: uuidv4(),
role: 'assistant',
content,
timestamp: new Date(),
toolResults
};
}
export function createLoadingMessage(): ChatMessage {
return {
id: uuidv4(),
role: 'assistant',
content: '',
timestamp: new Date(),
isLoading: true
};
}

View File

@ -0,0 +1,454 @@
import {DiagramEntity, DiagramEntityType, DiagramTemplates} from "../../diagram/types/diagramEntity";
import {
ClearDiagramParams,
COLOR_NAME_TO_HEX,
ConnectEntitiesParams,
CreateEntityParams,
ModifyConnectionParams,
ModifyEntityParams,
RemoveEntityParams,
SHAPE_TO_TEMPLATE,
ToolResult
} from "../types/chatTypes";
import {v4 as uuidv4} from 'uuid';
import log from 'loglevel';
const logger = log.getLogger('entityBridge');
logger.setLevel('debug');
function resolveColor(color?: string): string {
if (!color) return '#0000ff';
const lower = color.toLowerCase();
if (COLOR_NAME_TO_HEX[lower]) {
return COLOR_NAME_TO_HEX[lower];
}
if (color.startsWith('#')) {
return color;
}
return '#0000ff';
}
interface ResolvedEntity {
id: string | null;
label: string | null;
}
/**
* Resolve an entity label or ID to actual entity ID and label
*/
function resolveEntity(target: string): Promise<ResolvedEntity> {
logger.debug('[resolveEntity] Resolving:', target);
return new Promise((resolve) => {
const requestId = 'req-' + Date.now() + '-' + Math.random();
const responseHandler = (e: CustomEvent) => {
if (e.detail.requestId !== requestId) return;
logger.debug('[resolveEntity] Got response:', e.detail);
document.removeEventListener('chatResolveEntityResponse', responseHandler as EventListener);
resolve({
id: e.detail.entityId,
label: e.detail.entityLabel
});
};
document.addEventListener('chatResolveEntityResponse', responseHandler as EventListener);
const event = new CustomEvent('chatResolveEntity', {
detail: {target, requestId},
bubbles: true
});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[resolveEntity] Timeout resolving:', target);
document.removeEventListener('chatResolveEntityResponse', responseHandler as EventListener);
resolve({id: null, label: null});
}, 5000);
});
}
export function createEntity(params: CreateEntityParams): ToolResult {
logger.debug('[createEntity] Creating entity:', params);
const id = 'id' + uuidv4();
const template = SHAPE_TO_TEMPLATE[params.shape];
const color = resolveColor(params.color);
const position = params.position || {x: 0, y: 1.5, z: 2};
const entity: DiagramEntity = {
id,
template,
type: DiagramEntityType.ENTITY,
color,
text: params.label || '',
position,
rotation: {x: 0, y: Math.PI, z: 0},
scale: {x: 0.1, y: 0.1, z: 0.1},
};
logger.debug('[createEntity] Dispatching chatCreateEntity event:', entity);
const event = new CustomEvent('chatCreateEntity', {
detail: {entity},
bubbles: true
});
document.dispatchEvent(event);
const result = {
toolName: 'create_entity',
success: true,
message: `Created ${params.shape}${params.label ? ` labeled "${params.label}"` : ''} at position (${position.x.toFixed(1)}, ${position.y.toFixed(1)}, ${position.z.toFixed(1)})`,
entityId: id
};
logger.debug('[createEntity] Done:', result);
return result;
}
export async function connectEntities(params: ConnectEntitiesParams): Promise<ToolResult> {
logger.debug('[connectEntities] Connecting:', params);
// Resolve labels to actual entity IDs and get their labels
const fromEntity = await resolveEntity(params.from);
const toEntity = await resolveEntity(params.to);
logger.debug('[connectEntities] Resolved from:', fromEntity, 'to:', toEntity);
if (!fromEntity.id) {
return {
toolName: 'connect_entities',
success: false,
message: `Could not find entity "${params.from}"`
};
}
if (!toEntity.id) {
return {
toolName: 'connect_entities',
success: false,
message: `Could not find entity "${params.to}"`
};
}
const id = 'id' + uuidv4();
const color = resolveColor(params.color);
// Generate default label from entity labels: "{from label} to {to label}"
const fromLabel = fromEntity.label || params.from;
const toLabel = toEntity.label || params.to;
const connectionLabel = `${fromLabel} to ${toLabel}`;
const entity: DiagramEntity = {
id,
template: DiagramTemplates.CONNECTION,
type: DiagramEntityType.ENTITY,
color,
text: connectionLabel,
from: fromEntity.id,
to: toEntity.id,
};
const event = new CustomEvent('chatCreateEntity', {
detail: {entity},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'connect_entities',
success: true,
message: `Connected "${fromLabel}" to "${toLabel}"`,
entityId: id
};
}
export function removeEntity(params: RemoveEntityParams): ToolResult {
const event = new CustomEvent('chatRemoveEntity', {
detail: {target: params.target},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'remove_entity',
success: true,
message: `Removed entity "${params.target}"`
};
}
export function modifyEntity(params: ModifyEntityParams): ToolResult {
const updates: Partial<DiagramEntity> = {};
if (params.color) {
updates.color = resolveColor(params.color);
}
if (params.label !== undefined) {
updates.text = params.label;
}
if (params.position) {
updates.position = params.position;
}
const event = new CustomEvent('chatModifyEntity', {
detail: {target: params.target, updates},
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'modify_entity',
success: true,
message: `Modified entity "${params.target}"`
};
}
export async function modifyConnection(params: ModifyConnectionParams): Promise<ToolResult> {
logger.debug('[modifyConnection] Modifying connection:', params);
// Determine how to find the connection
let connectionTarget: string | null = null;
if (params.target) {
// Direct target specified (connection label)
connectionTarget = params.target;
} else if (params.from && params.to) {
// Find by from/to entities
const fromEntity = await resolveEntity(params.from);
const toEntity = await resolveEntity(params.to);
if (!fromEntity.id) {
return {
toolName: 'modify_connection',
success: false,
message: `Could not find source entity "${params.from}"`
};
}
if (!toEntity.id) {
return {
toolName: 'modify_connection',
success: false,
message: `Could not find destination entity "${params.to}"`
};
}
// Use a special format to identify connection by from/to
connectionTarget = `connection:${fromEntity.id}:${toEntity.id}`;
} else {
return {
toolName: 'modify_connection',
success: false,
message: 'Must specify either "target" (connection label) or both "from" and "to" entities'
};
}
const updates: Partial<DiagramEntity> = {};
if (params.color) {
updates.color = resolveColor(params.color);
}
if (params.label !== undefined) {
updates.text = params.label;
}
const event = new CustomEvent('chatModifyConnection', {
detail: {target: connectionTarget, updates},
bubbles: true
});
document.dispatchEvent(event);
const labelInfo = params.label ? ` with label "${params.label}"` : '';
const colorInfo = params.color ? ` with color "${params.color}"` : '';
return {
toolName: 'modify_connection',
success: true,
message: `Modified connection${labelInfo}${colorInfo}`
};
}
export function listEntities(): Promise<ToolResult> {
logger.debug('[listEntities] Listing entities...');
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const entities = e.detail.entities as Array<{
id: string;
template: string;
text: string;
position: { x: number; y: number; z: number }
}>;
logger.debug('[listEntities] Got response, entities:', entities.length);
if (entities.length === 0) {
resolve({
toolName: 'list_entities',
success: true,
message: 'The diagram is empty.'
});
return;
}
const list = entities.map(e => {
const shape = e.template.replace('#', '').replace('-template', '');
return `- ${e.text || '(no label)'} (${shape}) at (${e.position?.x?.toFixed(1) || 0}, ${e.position?.y?.toFixed(1) || 0}, ${e.position?.z?.toFixed(1) || 0}) [id: ${e.id}]`;
}).join('\n');
resolve({
toolName: 'list_entities',
success: true,
message: `Current entities in the diagram:\n${list}`
});
};
document.addEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const event = new CustomEvent('chatListEntities', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[listEntities] Timeout waiting for response');
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve({
toolName: 'list_entities',
success: false,
message: 'Failed to list entities (timeout)'
});
}, 5000);
});
}
/**
* Get all entities for session syncing (returns raw entity data)
*/
export function getEntitiesForSync(): Promise<Array<{
id: string;
template: string;
text?: string;
color?: string;
position?: { x: number; y: number; z: number };
}>> {
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve(e.detail.entities || []);
};
document.addEventListener('chatListEntitiesResponse', responseHandler as EventListener);
const event = new CustomEvent('chatListEntities', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
document.removeEventListener('chatListEntitiesResponse', responseHandler as EventListener);
resolve([]);
}, 5000);
});
}
/**
* Clear all entities from the diagram
*/
export async function clearDiagram(params: ClearDiagramParams): Promise<ToolResult> {
if (!params.confirmed) {
return {
toolName: 'clear_diagram',
success: false,
message: 'Clearing the diagram requires confirmation. Please ask the user to confirm before proceeding.'
};
}
// Get all entities first
const entities = await getEntitiesForSync();
if (entities.length === 0) {
return {
toolName: 'clear_diagram',
success: true,
message: 'The diagram is already empty.'
};
}
// Dispatch clear event to remove all entities at once
const event = new CustomEvent('chatClearDiagram', {
bubbles: true
});
document.dispatchEvent(event);
return {
toolName: 'clear_diagram',
success: true,
message: `Cleared ${entities.length} entities from the diagram.`
};
}
/**
* Get the current camera position and orientation
* Returns world-space coordinates with ground-projected directions for intuitive placement
*/
export function getCameraPosition(): Promise<ToolResult> {
logger.debug('[getCameraPosition] Getting camera position...');
return new Promise((resolve) => {
const responseHandler = (e: CustomEvent) => {
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
const {position, forward, groundForward, groundRight} = e.detail;
logger.debug('[getCameraPosition] Got response:', e.detail);
// Compute example positions for each direction
const distance = 2;
const frontPos = {
x: position.x + groundForward.x * distance,
y: position.y,
z: position.z + groundForward.z * distance
};
const rightPos = {
x: position.x + groundRight.x * distance,
y: position.y,
z: position.z + groundRight.z * distance
};
const leftPos = {
x: position.x - groundRight.x * distance,
y: position.y,
z: position.z - groundRight.z * distance
};
const backPos = {
x: position.x - groundForward.x * distance,
y: position.y,
z: position.z - groundForward.z * distance
};
const message = `User's current view (world coordinates):
Position: (${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)})
Looking direction: (${forward.x.toFixed(2)}, ${forward.y.toFixed(2)}, ${forward.z.toFixed(2)})
Ground-plane directions (use these for left/right/forward/back):
Forward: (${groundForward.x.toFixed(2)}, 0, ${groundForward.z.toFixed(2)})
Right: (${groundRight.x.toFixed(2)}, 0, ${groundRight.z.toFixed(2)})
To place entities relative to user (${distance}m away, at eye level y=${position.y.toFixed(1)}):
FORWARD: (${frontPos.x.toFixed(2)}, ${frontPos.y.toFixed(2)}, ${frontPos.z.toFixed(2)})
BACK: (${backPos.x.toFixed(2)}, ${backPos.y.toFixed(2)}, ${backPos.z.toFixed(2)})
RIGHT: (${rightPos.x.toFixed(2)}, ${rightPos.y.toFixed(2)}, ${rightPos.z.toFixed(2)})
LEFT: (${leftPos.x.toFixed(2)}, ${leftPos.y.toFixed(2)}, ${leftPos.z.toFixed(2)})
Formula: position + (groundForward * distance) for forward, position + (groundRight * distance) for right, etc.`;
resolve({
toolName: 'get_camera_position',
success: true,
message
});
};
document.addEventListener('chatGetCameraResponse', responseHandler as EventListener);
const event = new CustomEvent('chatGetCamera', {bubbles: true});
document.dispatchEvent(event);
setTimeout(() => {
logger.warn('[getCameraPosition] Timeout waiting for response');
document.removeEventListener('chatGetCameraResponse', responseHandler as EventListener);
resolve({
toolName: 'get_camera_position',
success: false,
message: 'Failed to get camera position (timeout)'
});
}, 5000);
});
}

View File

@ -0,0 +1,149 @@
import {DiagramTemplates} from "../../diagram/types/diagramEntity";
export type ChatRole = 'user' | 'assistant';
export interface ChatMessage {
id: string;
role: ChatRole;
content: string;
timestamp: Date;
toolResults?: ToolResult[];
isLoading?: boolean;
}
export interface ToolResult {
toolName: string;
success: boolean;
message: string;
entityId?: string;
}
export interface CreateEntityParams {
shape: 'box' | 'sphere' | 'cylinder' | 'cone' | 'plane' | 'person';
color?: string;
label?: string;
position?: { x: number; y: number; z: number };
}
export interface ConnectEntitiesParams {
from: string;
to: string;
color?: string;
}
export interface RemoveEntityParams {
target: string;
}
export interface ModifyEntityParams {
target: string;
color?: string;
label?: string;
position?: { x: number; y: number; z: number };
}
export interface ClearDiagramParams {
confirmed: boolean;
}
export interface ModifyConnectionParams {
target?: string;
from?: string;
to?: string;
label?: string;
color?: string;
}
export interface SetModelParams {
model_id: string;
}
export type DiagramToolCall =
| { name: 'create_entity'; input: CreateEntityParams }
| { name: 'connect_entities'; input: ConnectEntitiesParams }
| { name: 'remove_entity'; input: RemoveEntityParams }
| { name: 'modify_entity'; input: ModifyEntityParams }
| { name: 'modify_connection'; input: ModifyConnectionParams }
| { name: 'list_entities'; input: Record<string, never> }
| { name: 'clear_diagram'; input: ClearDiagramParams }
| { name: 'get_camera_position'; input: Record<string, never> }
| { name: 'list_models'; input: Record<string, never> }
| { name: 'get_current_model'; input: Record<string, never> }
| { name: 'set_model'; input: SetModelParams };
export const SHAPE_TO_TEMPLATE: Record<CreateEntityParams['shape'], DiagramTemplates> = {
box: DiagramTemplates.BOX,
sphere: DiagramTemplates.SPHERE,
cylinder: DiagramTemplates.CYLINDER,
cone: DiagramTemplates.CONE,
plane: DiagramTemplates.PLANE,
person: DiagramTemplates.PERSON,
};
export const COLOR_NAME_TO_HEX: Record<string, string> = {
red: '#ff0000',
green: '#00ff00',
blue: '#0000ff',
yellow: '#ffff00',
orange: '#ffa500',
purple: '#4b0082',
cyan: '#00ffff',
pink: '#ff69b4',
white: '#ffffff',
black: '#222222',
brown: '#8b4513',
gray: '#778899',
grey: '#778899',
};
// Reverse mapping from hex to color name
export const HEX_TO_COLOR_NAME: Record<string, string> = Object.entries(COLOR_NAME_TO_HEX)
.reduce((acc, [name, hex]) => {
acc[hex.toLowerCase()] = name;
return acc;
}, {} as Record<string, string>);
// Template to shape name mapping
export const TEMPLATE_TO_SHAPE: Record<string, string> = {
'#box-template': 'box',
'#sphere-template': 'sphere',
'#cylinder-template': 'cylinder',
'#cone-template': 'cone',
'#plane-template': 'plane',
'#person-template': 'person',
};
// Session types
export interface SessionEntity {
id: string;
template: string;
text?: string;
color?: string;
position?: { x: number; y: number; z: number };
}
export interface SessionMessage {
role: ChatRole;
content: string;
toolResults?: ToolResult[];
timestamp: Date;
}
export interface DiagramSession {
id: string;
diagramId: string;
conversationHistory: SessionMessage[];
entities: SessionEntity[];
createdAt: Date;
lastAccess: Date;
}
export interface CreateSessionResponse {
session: DiagramSession;
isNew: boolean;
}
export interface SyncEntitiesResponse {
success: boolean;
entityCount: number;
}

View File

@ -36,6 +36,11 @@ export const webRouter = createBrowserRouter([
<Pricing/> <Pricing/>
</ProtectedRoute> </ProtectedRoute>
) )
}, {
path: "/db/local/:db",
element: (
<VrExperience/> // No ProtectedRoute - works offline, browser-only
)
}, { }, {
path: "/db/public/:db", path: "/db/public/:db",
element: ( element: (

View File

@ -56,7 +56,7 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
createDiagram: 'basic', // Guests can create diagrams createDiagram: 'basic', // Guests can create diagrams
createFromTemplate: 'coming-soon', // Coming soon for guests createFromTemplate: 'coming-soon', // Coming soon for guests
manageDiagrams: 'basic', // Guests can manage their local diagrams manageDiagrams: 'basic', // Guests can manage their local diagrams
shareCollaborate: 'coming-soon', // Coming soon for guests shareCollaborate: 'on', // Share diagrams via link
privateDesigns: 'coming-soon', // Coming soon for guests privateDesigns: 'coming-soon', // Coming soon for guests
encryptedDesigns: 'pro', // No encryption for guests encryptedDesigns: 'pro', // No encryption for guests
editData: 'coming-soon', // Guests can edit data editData: 'coming-soon', // Guests can edit data
@ -74,27 +74,27 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
export const BASIC_FEATURE_CONFIG: FeatureConfig = { export const BASIC_FEATURE_CONFIG: FeatureConfig = {
tier: 'basic', tier: 'basic',
pages: { pages: {
examples: 'off', examples: 'coming-soon',
documentation: 'off', documentation: 'coming-soon',
pricing: 'coming-soon', pricing: 'coming-soon',
vrExperience: 'on', vrExperience: 'on',
}, },
features: { features: {
createDiagram: 'on', createDiagram: 'on',
createFromTemplate: 'off', createFromTemplate: 'coming-soon',
manageDiagrams: 'off', manageDiagrams: 'on',
shareCollaborate: 'off', shareCollaborate: 'on',
privateDesigns: 'off', privateDesigns: 'coming-soon',
encryptedDesigns: 'off', encryptedDesigns: 'pro',
editData: 'off', editData: 'coming-soon',
config: 'off', config: 'on',
enterImmersive: 'off', enterImmersive: 'on',
launchMetaQuest: 'off', launchMetaQuest: 'on',
}, },
limits: { limits: {
maxDiagrams: 0, maxDiagrams: 10,
maxCollaborators: 0, maxCollaborators: 0,
storageQuotaMB: 0, storageQuotaMB: 200,
}, },
}; };

View File

@ -1,10 +1,81 @@
export function getPath(): string { export function getPath(): string {
const path = window.location.pathname.split('/'); const path = window.location.pathname.split('/');
// Handle /db/local/:db, /db/public/:db, or /db/private/:db patterns
if (path.length >= 4 && path[1] === 'db') {
return path[3];
}
// Legacy pattern /db/:db
if (path.length == 3 && path[1]) { if (path.length == 3 && path[1]) {
return path[2]; return path[2];
} else { }
return null; return null;
} }
/**
* Check if the current path is a local database (no sync)
* Local paths: /db/local/:db
*/
export function isLocalPath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'local';
}
/**
* Check if the current path is a public database
* Public paths: /db/public/:db
*/
export function isPublicPath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'public';
}
/**
* Check if the current path is a private database
* Private paths: /db/private/:db
*/
export function isPrivatePath(): boolean {
const path = window.location.pathname.split('/');
return path.length >= 3 && path[1] === 'db' && path[2] === 'private';
}
/**
* Get the database type from the current path
*/
export function getDbType(): 'local' | 'public' | 'private' | null {
const path = window.location.pathname.split('/');
if (path.length >= 3 && path[1] === 'db') {
if (path[2] === 'local') return 'local';
if (path[2] === 'public') return 'public';
if (path[2] === 'private') return 'private';
}
return null;
}
/**
* Check if the current diagram should sync with the server
* Only public and private paths should sync; local is browser-only
*/
export function shouldSync(): boolean {
const dbType = getDbType();
return dbType === 'public' || dbType === 'private';
}
/**
* Get the full database path for PouchDB sync
* Returns: public-{dbname} or private-{dbname}
* Returns null for local paths (no sync)
* Uses dash separator instead of slash for express-pouchdb compatibility
*/
export function getRemoteDbPath(): string | null {
const path = window.location.pathname.split('/');
if (path.length >= 4 && path[1] === 'db') {
const type = path[2]; // 'local', 'public', or 'private'
// Local paths don't sync
if (type === 'local') return null;
const dbName = path[3];
return `${type}-${dbName}`;
}
return null;
} }
export function getParameter(name: string) { export function getParameter(name: string) {

View File

@ -23,7 +23,6 @@ import {PouchData} from "./integration/database/pouchData";
const webGpu = false; const webGpu = false;
log.setLevel('debug', false); log.setLevel('debug', false);
log.getLogger('PouchdbPersistenceManager').setLevel('debug', false);
export default class VrApp { export default class VrApp {
//preTasks = [havokModule]; //preTasks = [havokModule];
private logger: Logger = log.getLogger('App'); private logger: Logger = log.getLogger('App');

View File

@ -39,23 +39,8 @@ export default defineConfig(({mode}) => {
'^/api/images': { '^/api/images': {
target: 'https://www.deepdiagram.com/', target: 'https://www.deepdiagram.com/',
changeOrigin: true, changeOrigin: true,
},
'^/api/claude': {
target: 'https://api.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const apiKey = env.ANTHROPIC_API_KEY;
console.log(` API KEY: ${apiKey}`);
if (apiKey) {
proxyReq.setHeader('x-api-key', apiKey);
proxyReq.setHeader('anthropic-version', '2023-06-01');
}
});
}
} }
// /api/claude is now handled by Express server
} }
}, },
@ -73,22 +58,8 @@ export default defineConfig(({mode}) => {
'^/api/images': { '^/api/images': {
target: 'https://www.deepdiagram.com/', target: 'https://www.deepdiagram.com/',
changeOrigin: true, changeOrigin: true,
},
'^/api/claude': {
target: 'https://api.anthropic.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/claude/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
const apiKey = env.ANTHROPIC_API_KEY;
console.log(` API KEY: ${apiKey}`);
if (apiKey) {
proxyReq.setHeader('x-api-key', apiKey);
proxyReq.setHeader('anthropic-version', '2023-06-01');
}
});
}
} }
// /api/claude is now handled by Express server
} }
}, },
base: "/" base: "/"