Compare commits
No commits in common. "1e174e81d3dd171c50bd181bda63329e86b654e2" and "54e5017c388fda6471d3f6075967716c5901b79e" have entirely different histories.
1e174e81d3
...
54e5017c38
31
.github/workflows/build.yml
vendored
31
.github/workflows/build.yml
vendored
@ -1,31 +0,0 @@
|
||||
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
1
.gitignore
vendored
@ -25,4 +25,3 @@ dist-ssr
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
/data/
|
||||
|
||||
@ -1,403 +0,0 @@
|
||||
# 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
|
||||
@ -1,167 +0,0 @@
|
||||
# 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
150
SHARING_PLAN.md
@ -1,150 +0,0 @@
|
||||
# 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
179
SYNC_PLAN.md
@ -1,179 +0,0 @@
|
||||
# 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
2964
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "immersive",
|
||||
"private": true,
|
||||
"version": "0.0.8-43",
|
||||
"version": "0.0.8-34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node server.js",
|
||||
"dev": "vite",
|
||||
"test": "vitest",
|
||||
"build": "node versionBump.js && vite build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"start:api": "API_ONLY=true node server.js",
|
||||
"preview": "vite preview",
|
||||
"socket": "node server/server.js",
|
||||
"serve": "node server.js",
|
||||
"serverBuild": "cd server && tsc",
|
||||
"havok": "cp ./node_modules/@babylonjs/havok/lib/esm/HavokPhysics.wasm ./node_modules/.vite/deps"
|
||||
},
|
||||
@ -27,46 +27,38 @@
|
||||
"@babylonjs/materials": "^8.16.2",
|
||||
"@babylonjs/serializers": "^8.16.2",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@giphy/js-fetch-api": "^5.6.0",
|
||||
"@giphy/react-components": "^9.6.0",
|
||||
"@mantine/core": "^7.17.8",
|
||||
"@mantine/form": "^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",
|
||||
"@picovoice/cobra-web": "^2.0.3",
|
||||
"@picovoice/eagle-web": "^1.0.0",
|
||||
"@picovoice/web-voice-processor": "^4.0.9",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@types/node": "^18.14.0",
|
||||
"@types/react": "^18.2.72",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"axios": "^1.10.0",
|
||||
"canvas-hypertxt": "1.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"events": "^3.3.0",
|
||||
"express": "^5.2.1",
|
||||
"express-pouchdb": "^4.2.0",
|
||||
"hash-wasm": "4.11.0",
|
||||
"hls.js": "^1.1.4",
|
||||
"js-crypto-aes": "1.0.6",
|
||||
"leveldown": "^6.1.1",
|
||||
"loglevel": "^1.9.1",
|
||||
"meaningful-string": "^1.4.0",
|
||||
"peer-lite": "2.0.2",
|
||||
"use-pouchdb": "^2.0.2",
|
||||
"pouchdb": "^8.0.1",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-find": "^8.0.1",
|
||||
"query-string": "^8.1.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"recordrtc": "^5.6.0",
|
||||
"rfc4648": "^1.5.3",
|
||||
"round": "^2.0.1",
|
||||
"uint8-to-b64": "^1.0.2",
|
||||
"use-pouchdb": "^2.0.2",
|
||||
"uuid": "^9.0.1",
|
||||
"vite-express": "^0.21.1",
|
||||
"websocket": "^1.0.34",
|
||||
"websocket-ts": "^2.1.5"
|
||||
},
|
||||
|
||||
89
public/api/user/README.md
Normal file
89
public/api/user/README.md
Normal file
@ -0,0 +1,89 @@
|
||||
# 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
|
||||
26
public/api/user/features
Normal file
26
public/api/user/features
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-basic.json
Normal file
26
public/api/user/features-basic.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-free.json
Normal file
26
public/api/user/features-free.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-none.json
Normal file
26
public/api/user/features-none.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
26
public/api/user/features-pro.json
Normal file
26
public/api/user/features-pro.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
89
server.js
89
server.js
@ -1,92 +1,13 @@
|
||||
import express from "express";
|
||||
import ViteExpress from "vite-express";
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
import apiRoutes from "./server/api/index.js";
|
||||
import { pouchApp, PouchDB } from "./server/services/databaseService.js";
|
||||
import { dbAuthMiddleware } from "./server/middleware/dbAuth.js";
|
||||
import expressProxy from "express-http-proxy";
|
||||
|
||||
// Load .env.local first, then fall back to .env
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
|
||||
|
||||
|
||||
const app = express();
|
||||
app.use("/api", expressProxy("local.immersiveidea.com"));
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
}
|
||||
ViteExpress.listen(app, process.env.PORT || 3001, () => console.log("Server is listening..."));
|
||||
|
||||
@ -1,143 +0,0 @@
|
||||
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;
|
||||
@ -1,26 +0,0 @@
|
||||
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;
|
||||
@ -1,178 +0,0 @@
|
||||
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;
|
||||
@ -1,144 +0,0 @@
|
||||
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;
|
||||
@ -1,155 +0,0 @@
|
||||
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;
|
||||
@ -1,88 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@ -1,158 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
})
|
||||
}))
|
||||
};
|
||||
}
|
||||
@ -1,245 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core";
|
||||
import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, WebXRDefaultExperience} from "@babylonjs/core";
|
||||
import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity";
|
||||
import log from "loglevel";
|
||||
|
||||
@ -13,7 +13,6 @@ import {UserModelType} from "../users/userTypes";
|
||||
import {vectoxys} from "./functions/vectorConversion";
|
||||
import {controllerObservable} from "../controllers/controllers";
|
||||
import {ControllerEvent} from "../controllers/types/controllerEvent";
|
||||
import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes";
|
||||
|
||||
|
||||
export class DiagramManager {
|
||||
@ -108,13 +107,6 @@ export class DiagramManager {
|
||||
document.addEventListener('chatCreateEntity', (event: CustomEvent) => {
|
||||
const {entity} = event.detail;
|
||||
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, {
|
||||
diagramEntity: entity,
|
||||
actionManager: this._diagramEntityActionManager
|
||||
@ -150,61 +142,16 @@ export class DiagramManager {
|
||||
if (entity) {
|
||||
const diagramObject = this._diagramObjects.get(entity.id);
|
||||
if (diagramObject) {
|
||||
// Apply updates using setters (each setter handles its own DB notification)
|
||||
if (updates.text !== undefined) {
|
||||
diagramObject.text = updates.text;
|
||||
}
|
||||
if (updates.color !== undefined) {
|
||||
diagramObject.color = updates.color;
|
||||
// Note: color and position updates would require additional DiagramObject methods
|
||||
const updatedEntity = {...entity, ...updates};
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -214,7 +161,6 @@ export class DiagramManager {
|
||||
id: obj.diagramEntity.id,
|
||||
template: obj.diagramEntity.template,
|
||||
text: obj.diagramEntity.text || '',
|
||||
color: obj.diagramEntity.color,
|
||||
position: obj.diagramEntity.position
|
||||
}));
|
||||
const responseEvent = new CustomEvent('chatListEntitiesResponse', {
|
||||
@ -224,89 +170,6 @@ export class DiagramManager {
|
||||
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");
|
||||
}
|
||||
|
||||
@ -356,42 +219,6 @@ export class DiagramManager {
|
||||
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) {
|
||||
let diagramObject = this._diagramObjects.get(event?.entity?.id);
|
||||
switch (event.type) {
|
||||
|
||||
@ -142,56 +142,6 @@ export class DiagramObject {
|
||||
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) {
|
||||
if (this._label) {
|
||||
this._label.dispose();
|
||||
@ -319,26 +269,30 @@ export class DiagramObject {
|
||||
if (!this._meshRemovedObserver) {
|
||||
this._meshRemovedObserver = this._scene.onMeshRemovedObservable.add((mesh) => {
|
||||
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) {
|
||||
case this._from:
|
||||
this._fromMesh = null;
|
||||
this._lastFromPosition = null;
|
||||
this._meshesPresent = false;
|
||||
this._observingStart = Date.now(); // Reset timeout
|
||||
this._eventObservable.notifyObservers({
|
||||
type: DiagramEventType.REMOVE,
|
||||
entity: this._diagramEntity
|
||||
}, DiagramEventObserverMask.ALL);
|
||||
this.dispose();
|
||||
break;
|
||||
case this._to:
|
||||
this._toMesh = null;
|
||||
this._lastToPosition = null;
|
||||
this._meshesPresent = false;
|
||||
this._observingStart = Date.now(); // Reset timeout
|
||||
break;
|
||||
this._eventObservable.notifyObservers({
|
||||
type: DiagramEventType.REMOVE,
|
||||
entity: this._diagramEntity
|
||||
}, DiagramEventObserverMask.ALL);
|
||||
this.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}, -1, false, this);
|
||||
}
|
||||
if (!this._sceneObserver) {
|
||||
|
||||
@ -5,78 +5,18 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
|
||||
import log, {Logger} from "loglevel";
|
||||
import PouchDB from 'pouchdb';
|
||||
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
||||
import {getDbType, getRemoteDbPath, shouldSync} from "../../util/functions/getPath";
|
||||
|
||||
export class PouchData {
|
||||
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
||||
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
||||
private _db: PouchDB;
|
||||
private _remote: PouchDB;
|
||||
private _diagramManager: DiagramManager;
|
||||
private _logger: Logger = log.getLogger('PouchData');
|
||||
private _dbName: string;
|
||||
private _syncHandler: any;
|
||||
|
||||
constructor(dbname: string) {
|
||||
this._db = new PouchDB(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) {
|
||||
this._diagramManager = diagramManager;
|
||||
@ -197,52 +137,4 @@ export class PouchData {
|
||||
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');
|
||||
}
|
||||
}
|
||||
409
src/integration/database/pouchdbPersistenceManager.ts
Normal file
409
src/integration/database/pouchdbPersistenceManager.ts
Normal file
@ -0,0 +1,409 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,247 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -7,13 +7,11 @@ import {useFeatureState} from "../hooks/useFeatures";
|
||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||
import UpgradeBadge from "../components/UpgradeBadge";
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
const logger = log.getLogger('createDiagramModal');
|
||||
const db = usePouch();
|
||||
const { loginWithRedirect } = useAuth0();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Feature flags
|
||||
const privateDesignsState = useFeatureState('privateDesigns');
|
||||
@ -45,14 +43,7 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
logger.warn('cannot find directory', err);
|
||||
}
|
||||
const id = 'diagram-' + v4();
|
||||
// All new diagrams start as local (browser-only)
|
||||
const newDiagram = {
|
||||
...diagram,
|
||||
_id: id,
|
||||
type: 'diagram',
|
||||
storageType: 'local',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
const newDiagram = {...diagram, _id: id, type: 'diagram'};
|
||||
if (!doc) {
|
||||
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
|
||||
} else {
|
||||
@ -65,8 +56,6 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
await db.put(doc);
|
||||
}
|
||||
closeCreate();
|
||||
// Navigate to the local diagram
|
||||
navigate(`/db/local/${id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {Badge, Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
|
||||
import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
|
||||
import React from "react";
|
||||
import {useDoc, usePouch} from "use-pouchdb";
|
||||
import {IconTrash, IconDownload} from "@tabler/icons-react";
|
||||
@ -7,26 +7,6 @@ import log from "loglevel";
|
||||
import {useFeatureLimit} from "../hooks/useFeatures";
|
||||
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}) {
|
||||
const logger = log.getLogger('manageDiagramsModal');
|
||||
const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []});
|
||||
@ -54,16 +34,10 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
|
||||
};
|
||||
|
||||
const cards = diagrams.map((diagram) => {
|
||||
const badgeInfo = getStorageTypeBadge(diagram.storageType);
|
||||
const diagramPath = getDiagramPath(diagram);
|
||||
|
||||
return (
|
||||
<Card key={diagram._id}>
|
||||
<Card.Section>
|
||||
<Group justify="space-between" p="xs">
|
||||
<Container w={400} h={64}>{diagram.name}</Container>
|
||||
<Badge color={badgeInfo.color} size="sm">{badgeInfo.label}</Badge>
|
||||
</Group>
|
||||
<Container w={512} h={64}>{diagram.name}</Container>
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<Container w={512} h={128}>
|
||||
@ -72,7 +46,7 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<Group justify="space-evenly">
|
||||
<Button component={Link} key="examples" to={diagramPath} p={5} c="myColor"
|
||||
<Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor"
|
||||
bg="none">Select</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@ -20,9 +20,6 @@ import VREntryPrompt from "../components/VREntryPrompt";
|
||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||
import UpgradeBadge from "../components/UpgradeBadge";
|
||||
import ChatPanel from "../components/ChatPanel";
|
||||
import {getDbType} from "../../util/functions/getPath";
|
||||
import PouchDB from 'pouchdb';
|
||||
import {v4} from "uuid";
|
||||
|
||||
let vrApp: VrApp = null;
|
||||
|
||||
@ -43,7 +40,6 @@ export default function VrExperience() {
|
||||
const createFromTemplateState = useFeatureState('createFromTemplate');
|
||||
const manageDiagramsState = useFeatureState('manageDiagrams');
|
||||
const shareCollaborateState = useFeatureState('shareCollaborate');
|
||||
console.log('[Share] shareCollaborateState:', shareCollaborateState);
|
||||
const editDataState = useFeatureState('editData');
|
||||
const configState = useFeatureState('config');
|
||||
const enterImmersiveState = useFeatureState('enterImmersive');
|
||||
@ -125,96 +121,6 @@ export default function VrExperience() {
|
||||
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
||||
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(() => {
|
||||
const canvas = document.getElementById('vrCanvas');
|
||||
if (!canvas) {
|
||||
@ -225,7 +131,6 @@ export default function VrExperience() {
|
||||
logger.debug('destroying vrApp');
|
||||
vrApp.dispose();
|
||||
}
|
||||
console.log('[Share] Initializing VrApp with dbName:', dbName);
|
||||
vrApp = new VrApp(canvas as HTMLCanvasElement, dbName);
|
||||
closeManage();
|
||||
|
||||
@ -317,7 +222,7 @@ export default function VrExperience() {
|
||||
<VrTemplate>
|
||||
{/* Guest Mode Banner - Non-aggressive, dismissible (hidden for demo) */}
|
||||
{!isAuthenticated && !guestBannerDismissed && dbName !== 'demo' && (
|
||||
<Affix position={{top: 20, right: 20}} style={{maxWidth: 400}} zIndex={100}>
|
||||
<Affix position={{top: 20, right: 20}} style={{maxWidth: 400}}>
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
@ -343,8 +248,8 @@ export default function VrExperience() {
|
||||
<ConfigModal closeConfig={closeConfig} configOpened={configOpened}/>
|
||||
{createModal()}
|
||||
{manageModal()}
|
||||
<Affix position={{top: 30, left: 60}} zIndex={100}>
|
||||
<Menu opened={menuOpened} onChange={setMenuOpened} position="bottom-start" zIndex={100}>
|
||||
<Affix position={{top: 30, left: 60}}>
|
||||
<Menu opened={menuOpened} onChange={setMenuOpened}>
|
||||
<Menu.Target>
|
||||
<Burger opened={menuOpened} onClick={toggleMenu} size="xl"/>
|
||||
</Menu.Target>
|
||||
@ -424,9 +329,9 @@ export default function VrExperience() {
|
||||
|
||||
{shouldShow(shareCollaborateState) && (
|
||||
<VrMenuItem
|
||||
tip="Share your model with others. Creates a shareable link that copies to clipboard."
|
||||
tip="Share your model with others and collaborate in real time with others. This is a paid feature."
|
||||
label="Share"
|
||||
onClick={getClickHandler(shareCollaborateState, handleShare)}
|
||||
onClick={getClickHandler(shareCollaborateState, null)}
|
||||
availableIcon={getFeatureIndicator(shareCollaborateState)}/>
|
||||
)}
|
||||
|
||||
@ -456,7 +361,7 @@ export default function VrExperience() {
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1
|
||||
zIndex: 1000
|
||||
}}/>
|
||||
</div>
|
||||
{chatOpen && <ChatPanel onClose={() => setChatOpen(false)}/>}
|
||||
|
||||
@ -1,652 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,454 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -1,149 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -36,11 +36,6 @@ export const webRouter = createBrowserRouter([
|
||||
<Pricing/>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}, {
|
||||
path: "/db/local/:db",
|
||||
element: (
|
||||
<VrExperience/> // No ProtectedRoute - works offline, browser-only
|
||||
)
|
||||
}, {
|
||||
path: "/db/public/:db",
|
||||
element: (
|
||||
|
||||
@ -56,7 +56,7 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
|
||||
createDiagram: 'basic', // Guests can create diagrams
|
||||
createFromTemplate: 'coming-soon', // Coming soon for guests
|
||||
manageDiagrams: 'basic', // Guests can manage their local diagrams
|
||||
shareCollaborate: 'on', // Share diagrams via link
|
||||
shareCollaborate: 'coming-soon', // Coming soon for guests
|
||||
privateDesigns: 'coming-soon', // Coming soon for guests
|
||||
encryptedDesigns: 'pro', // No encryption for guests
|
||||
editData: 'coming-soon', // Guests can edit data
|
||||
@ -74,27 +74,27 @@ export const DEFAULT_FEATURE_CONFIG: FeatureConfig = {
|
||||
export const BASIC_FEATURE_CONFIG: FeatureConfig = {
|
||||
tier: 'basic',
|
||||
pages: {
|
||||
examples: 'coming-soon',
|
||||
documentation: 'coming-soon',
|
||||
examples: 'off',
|
||||
documentation: 'off',
|
||||
pricing: 'coming-soon',
|
||||
vrExperience: 'on',
|
||||
},
|
||||
features: {
|
||||
createDiagram: 'on',
|
||||
createFromTemplate: 'coming-soon',
|
||||
manageDiagrams: 'on',
|
||||
shareCollaborate: 'on',
|
||||
privateDesigns: 'coming-soon',
|
||||
encryptedDesigns: 'pro',
|
||||
editData: 'coming-soon',
|
||||
config: 'on',
|
||||
enterImmersive: 'on',
|
||||
launchMetaQuest: 'on',
|
||||
createFromTemplate: 'off',
|
||||
manageDiagrams: 'off',
|
||||
shareCollaborate: 'off',
|
||||
privateDesigns: 'off',
|
||||
encryptedDesigns: 'off',
|
||||
editData: 'off',
|
||||
config: 'off',
|
||||
enterImmersive: 'off',
|
||||
launchMetaQuest: 'off',
|
||||
},
|
||||
limits: {
|
||||
maxDiagrams: 10,
|
||||
maxDiagrams: 0,
|
||||
maxCollaborators: 0,
|
||||
storageQuotaMB: 200,
|
||||
storageQuotaMB: 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,81 +1,10 @@
|
||||
export function getPath(): string {
|
||||
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]) {
|
||||
return path[2];
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
|
||||
@ -23,6 +23,7 @@ import {PouchData} from "./integration/database/pouchData";
|
||||
const webGpu = false;
|
||||
|
||||
log.setLevel('debug', false);
|
||||
log.getLogger('PouchdbPersistenceManager').setLevel('debug', false);
|
||||
export default class VrApp {
|
||||
//preTasks = [havokModule];
|
||||
private logger: Logger = log.getLogger('App');
|
||||
|
||||
@ -39,8 +39,23 @@ export default defineConfig(({mode}) => {
|
||||
'^/api/images': {
|
||||
target: 'https://www.deepdiagram.com/',
|
||||
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
|
||||
}
|
||||
|
||||
},
|
||||
@ -58,8 +73,22 @@ export default defineConfig(({mode}) => {
|
||||
'^/api/images': {
|
||||
target: 'https://www.deepdiagram.com/',
|
||||
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: "/"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user