diff --git a/.env.example b/.env.example index ebd3845..b17bb28 100644 --- a/.env.example +++ b/.env.example @@ -4,16 +4,17 @@ VITE_GOOGLE_MAPS_API_KEY='your_google_maps_api_key' VITE_DAILY_DOMAIN='your_daily_domain' VITE_TLDRAW_WORKER_URL='your_worker_url' -# AI Orchestrator (Primary - Netcup RS 8000) -VITE_AI_ORCHESTRATOR_URL='http://159.195.32.209:8000' -# Or use domain when DNS is configured: -# VITE_AI_ORCHESTRATOR_URL='https://ai-api.jeffemmett.com' +# AI Configuration +# AI Orchestrator with Ollama (FREE local AI - highest priority) +VITE_OLLAMA_URL='https://ai.jeffemmett.com' -# RunPod API (Fallback/Direct Access) +# RunPod API (Primary AI provider when Ollama unavailable) +# Users don't need their own API keys - RunPod is pre-configured VITE_RUNPOD_API_KEY='your_runpod_api_key_here' -VITE_RUNPOD_TEXT_ENDPOINT_ID='your_text_endpoint_id' -VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' -VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' +VITE_RUNPOD_TEXT_ENDPOINT_ID='your_text_endpoint_id' # vLLM for chat/text +VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' # Automatic1111/SD +VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2 +VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX # Worker-only Variables (Do not prefix with VITE_) CLOUDFLARE_API_TOKEN='your_cloudflare_token' diff --git a/multmux/.dockerignore b/multmux/.dockerignore new file mode 100644 index 0000000..74c5ecc --- /dev/null +++ b/multmux/.dockerignore @@ -0,0 +1,8 @@ +node_modules +packages/*/node_modules +packages/*/dist +*.log +.git +.gitignore +README.md +infrastructure/ diff --git a/multmux/Dockerfile b/multmux/Dockerfile new file mode 100644 index 0000000..7c5d34f --- /dev/null +++ b/multmux/Dockerfile @@ -0,0 +1,32 @@ +# mulTmux Server Dockerfile +FROM node:20-slim + +# Install tmux and build dependencies for node-pty +RUN apt-get update && apt-get install -y \ + tmux \ + python3 \ + make \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy workspace root files +COPY package.json ./ +COPY tsconfig.json ./ + +# Copy packages +COPY packages/server ./packages/server +COPY packages/cli ./packages/cli + +# Install dependencies (including node-pty native compilation) +RUN npm install --workspaces + +# Build TypeScript +RUN npm run build + +# Expose port +EXPOSE 3002 + +# Run the server +CMD ["node", "packages/server/dist/index.js"] diff --git a/multmux/docker-compose.yml b/multmux/docker-compose.yml new file mode 100644 index 0000000..948cad8 --- /dev/null +++ b/multmux/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + multmux: + build: . + container_name: multmux-server + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3002 + labels: + - "traefik.enable=true" + # HTTP router + - "traefik.http.routers.multmux.rule=Host(`terminal.jeffemmett.com`)" + - "traefik.http.routers.multmux.entrypoints=web" + - "traefik.http.services.multmux.loadbalancer.server.port=3002" + # WebSocket support - Traefik handles this automatically for HTTP/1.1 upgrades + # Enable sticky sessions for WebSocket connections + - "traefik.http.services.multmux.loadbalancer.sticky.cookie=true" + - "traefik.http.services.multmux.loadbalancer.sticky.cookie.name=multmux_session" + networks: + - traefik-public + # Health check + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + traefik-public: + external: true diff --git a/package-lock.json b/package-lock.json index 2df4dad..443cbf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@automerge/automerge-repo-storage-indexeddb": "^2.5.0", "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", @@ -298,6 +299,31 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@automerge/automerge-repo-storage-indexeddb": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-2.5.0.tgz", + "integrity": "sha512-7MJYJ5S6K7dHlbvs5/u/v9iexqOeprU/qQonup28r2IoVqwzjuN5ezaoVk6JRBMDI/ZxWfU4rNrqVrVlB49yXA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.5.0" + } + }, + "node_modules/@automerge/automerge-repo-storage-indexeddb/node_modules/@automerge/automerge-repo": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.5.0.tgz", + "integrity": "sha512-bdxuMuKmxw0ZjwQXecrIX1VrHXf445bYCftNJJ5vqgGWVvINB5ZKFYAbtgPIyu1Y0TXQKvc6eqESaDeL+g8MmA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "2.2.8 - 3", + "bs58check": "^3.0.1", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "fast-sha256": "^1.3.0", + "uuid": "^9.0.0", + "xstate": "^5.9.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/package.json b/package.json index 24fc78a..d41b7e8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@automerge/automerge": "^3.1.1", "@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0", + "@automerge/automerge-repo-storage-indexeddb": "^2.5.0", "@chengsokdara/use-whisper": "^0.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", diff --git a/src/CmdK.tsx b/src/CmdK.tsx index 3965c60..8381947 100644 --- a/src/CmdK.tsx +++ b/src/CmdK.tsx @@ -66,11 +66,19 @@ export const CmdK = () => { ) const selected = editor.getSelectedShapeIds() - const inView = editor - .getShapesAtPoint(editor.getViewportPageBounds().center, { - margin: 1200, - }) - .map((o) => o.id) + let inView: TLShapeId[] = [] + try { + inView = editor + .getShapesAtPoint(editor.getViewportPageBounds().center, { + margin: 1200, + }) + .map((o) => o.id) + } catch (e) { + // Some shapes may have invalid geometry (e.g., zero-length arrows) + // Fall back to getting all shapes on the current page + console.warn('getShapesAtPoint failed, falling back to all page shapes:', e) + inView = editor.getCurrentPageShapeIds() as unknown as TLShapeId[] + } return new Map([ ...nameToShapeIdMap, diff --git a/src/automerge/documentIdMapping.ts b/src/automerge/documentIdMapping.ts new file mode 100644 index 0000000..428e395 --- /dev/null +++ b/src/automerge/documentIdMapping.ts @@ -0,0 +1,250 @@ +/** + * Document ID Mapping Utility + * + * Manages the mapping between room IDs (human-readable slugs) and + * Automerge document IDs (automerge:xxxx format). + * + * This is necessary because: + * - Automerge requires specific document ID formats + * - We want to persist documents in IndexedDB with consistent IDs + * - Room IDs are user-friendly slugs that may not match Automerge's format + */ + +const DB_NAME = 'canvas-document-mappings' +const STORE_NAME = 'mappings' +const DB_VERSION = 1 + +interface DocumentMapping { + roomId: string + documentId: string + createdAt: number + lastAccessedAt: number +} + +let dbInstance: IDBDatabase | null = null + +/** + * Open the IndexedDB database for document ID mappings + */ +async function openDatabase(): Promise { + if (dbInstance) return dbInstance + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => { + console.error('Failed to open document mapping database:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + dbInstance = request.result + resolve(request.result) + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'roomId' }) + store.createIndex('documentId', 'documentId', { unique: true }) + store.createIndex('lastAccessedAt', 'lastAccessedAt', { unique: false }) + } + } + }) +} + +/** + * Get the Automerge document ID for a given room ID + * Returns null if no mapping exists + */ +export async function getDocumentId(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(roomId) + + request.onerror = () => { + console.error('Failed to get document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + const mapping = request.result as DocumentMapping | undefined + if (mapping) { + // Update last accessed time in background + updateLastAccessed(roomId).catch(console.error) + resolve(mapping.documentId) + } else { + resolve(null) + } + } + }) + } catch (error) { + console.error('Error getting document ID:', error) + return null + } +} + +/** + * Save a mapping between room ID and Automerge document ID + */ +export async function saveDocumentId(roomId: string, documentId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + const mapping: DocumentMapping = { + roomId, + documentId, + createdAt: Date.now(), + lastAccessedAt: Date.now() + } + + const request = store.put(mapping) + + request.onerror = () => { + console.error('Failed to save document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + console.log(`Saved document mapping: ${roomId} -> ${documentId}`) + resolve() + } + }) + } catch (error) { + console.error('Error saving document ID:', error) + throw error + } +} + +/** + * Update the last accessed timestamp for a room + */ +async function updateLastAccessed(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const getRequest = store.get(roomId) + + getRequest.onerror = () => reject(getRequest.error) + + getRequest.onsuccess = () => { + const mapping = getRequest.result as DocumentMapping | undefined + if (mapping) { + mapping.lastAccessedAt = Date.now() + store.put(mapping) + } + resolve() + } + }) + } catch (error) { + // Silent fail for background update + } +} + +/** + * Delete a document mapping (useful for cleanup) + */ +export async function deleteDocumentMapping(roomId: string): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.delete(roomId) + + request.onerror = () => { + console.error('Failed to delete document mapping:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + console.log(`Deleted document mapping for: ${roomId}`) + resolve() + } + }) + } catch (error) { + console.error('Error deleting document mapping:', error) + throw error + } +} + +/** + * Get all document mappings (useful for debugging/management) + */ +export async function getAllMappings(): Promise { + try { + const db = await openDatabase() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.getAll() + + request.onerror = () => { + console.error('Failed to get all document mappings:', request.error) + reject(request.error) + } + + request.onsuccess = () => { + resolve(request.result as DocumentMapping[]) + } + }) + } catch (error) { + console.error('Error getting all mappings:', error) + return [] + } +} + +/** + * Clean up old document mappings (documents not accessed in X days) + * This helps manage storage quota + */ +export async function cleanupOldMappings(maxAgeDays: number = 30): Promise { + try { + const db = await openDatabase() + const cutoffTime = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000) + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const index = store.index('lastAccessedAt') + const range = IDBKeyRange.upperBound(cutoffTime) + const request = index.openCursor(range) + + let deletedCount = 0 + + request.onerror = () => { + console.error('Failed to cleanup old mappings:', request.error) + reject(request.error) + } + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + cursor.delete() + deletedCount++ + cursor.continue() + } else { + console.log(`Cleaned up ${deletedCount} old document mappings`) + resolve(deletedCount) + } + } + }) + } catch (error) { + console.error('Error cleaning up old mappings:', error) + return 0 + } +} diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index ca627bb..b381d6c 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -3,8 +3,10 @@ import { TLStoreSnapshot, InstancePresenceRecordType } from "@tldraw/tldraw" import { CloudflareNetworkAdapter } from "./CloudflareAdapter" import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" import { TLStoreWithStatus } from "@tldraw/tldraw" -import { Repo, parseAutomergeUrl, stringifyAutomergeUrl } from "@automerge/automerge-repo" +import { Repo, parseAutomergeUrl, stringifyAutomergeUrl, AutomergeUrl } from "@automerge/automerge-repo" import { DocHandle } from "@automerge/automerge-repo" +import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb" +import { getDocumentId, saveDocumentId } from "./documentIdMapping" interface AutomergeSyncConfig { uri: string @@ -213,7 +215,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus } }, []) - const { repo, adapter } = useMemo(() => { + const { repo, adapter, storageAdapter } = useMemo(() => { const adapter = new CloudflareNetworkAdapter( workerUrl, roomId, @@ -224,18 +226,23 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // Store adapter ref for use in callbacks adapterRef.current = adapter + // Create IndexedDB storage adapter for offline persistence + // This stores Automerge documents locally in the browser + const storageAdapter = new IndexedDBStorageAdapter() + const repo = new Repo({ network: [adapter], + storage: storageAdapter, // Add IndexedDB storage for offline support // Enable sharing of all documents with all peers sharePolicy: async () => true }) // Log when sync messages are sent/received - adapter.on('message', (msg: any) => { + adapter.on('message', (_msg: any) => { // Message received from network }) - return { repo, adapter } + return { repo, adapter, storageAdapter } }, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate]) // Initialize Automerge document handle @@ -248,62 +255,122 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus // This ensures the WebSocket connection is established for sync await adapter.whenReady() - if (mounted) { - // CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID) - // Each client gets its own document, but Automerge sync protocol keeps them in sync - // The network adapter broadcasts sync messages between all clients in the same room - const handle = repo.create() + if (!mounted) return - // Wait for the handle to be ready - await handle.whenReady() + let handle: DocHandle + let loadedFromLocal = false - // CRITICAL: Always load initial data from the server - // The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document + // Check if we have a stored document ID mapping for this room + // This allows us to load the same document from IndexedDB on subsequent visits + const storedDocumentId = await getDocumentId(roomId) + + if (storedDocumentId) { + console.log(`Found stored document ID for room ${roomId}: ${storedDocumentId}`) try { - const response = await fetch(`${workerUrl}/room/${roomId}`) - if (response.ok) { - const serverDoc = await response.json() as TLStoreSnapshot - const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 - const serverRecordCount = Object.keys(serverDoc.store || {}).length + // Try to find the existing document in the repo (loads from IndexedDB) + // repo.find() returns a Promise + const foundHandle = await repo.find(storedDocumentId as AutomergeUrl) + await foundHandle.whenReady() + handle = foundHandle - // Document loaded from server + // Check if document has data + const localDoc = handle.doc() + const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0 + const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 - // Initialize the Automerge document with server data - if (serverDoc.store && serverRecordCount > 0) { - handle.change((doc: any) => { - // Initialize store if it doesn't exist - if (!doc.store) { - doc.store = {} - } - // Copy all records from server document - Object.entries(serverDoc.store).forEach(([id, record]) => { - doc.store[id] = record - }) - }) - - // Initialized Automerge document from server - } else { - // Server document is empty - starting fresh - } - } else if (response.status === 404) { - // No document found on server - starting fresh + if (localRecordCount > 0) { + console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`) + loadedFromLocal = true } else { - console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`) + console.log(`Document found in IndexedDB but is empty, will load from server`) } } catch (error) { - console.error("❌ Error loading initial document from server:", error) - // Continue anyway - user can still create new content + console.warn(`Failed to load document ${storedDocumentId} from IndexedDB:`, error) + // Fall through to create a new document } - - // Verify final document state - const finalDoc = handle.doc() as any - const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 - const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 - - // Automerge handle initialized and ready - setHandle(handle) - setIsLoading(false) } + + // If we didn't load from local storage, create a new document + if (!loadedFromLocal || !handle!) { + console.log(`Creating new Automerge document for room ${roomId}`) + handle = repo.create() + await handle.whenReady() + + // Save the mapping between roomId and the new document ID + const documentId = handle.url + if (documentId) { + await saveDocumentId(roomId, documentId) + console.log(`Saved new document mapping: ${roomId} -> ${documentId}`) + } + } + + if (!mounted) return + + // Sync with server to get latest data (or upload local changes if offline was edited) + // This ensures we're in sync even if we loaded from IndexedDB + try { + const response = await fetch(`${workerUrl}/room/${roomId}`) + if (response.ok) { + const serverDoc = await response.json() as TLStoreSnapshot + const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 + const serverRecordCount = Object.keys(serverDoc.store || {}).length + + // Get current local state + const localDoc = handle.doc() + const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0 + + // Merge server data with local data + // Automerge handles conflict resolution automatically via CRDT + if (serverDoc.store && serverRecordCount > 0) { + handle.change((doc: any) => { + // Initialize store if it doesn't exist + if (!doc.store) { + doc.store = {} + } + // Merge server records - Automerge will handle conflicts + Object.entries(serverDoc.store).forEach(([id, record]) => { + // Only add if not already present locally (local changes take precedence) + // This is a simple merge strategy - Automerge's CRDT will handle deeper conflicts + if (!doc.store[id]) { + doc.store[id] = record + } + }) + }) + + const finalDoc = handle.doc() + const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 + console.log(`Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`) + } else if (!loadedFromLocal) { + // Server is empty and we didn't load from local - fresh start + console.log(`Starting fresh - no data on server or locally`) + } + } else if (response.status === 404) { + // No document found on server + if (loadedFromLocal) { + console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`) + } else { + console.log(`No document found on server - starting fresh`) + } + } else { + console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`) + } + } catch (error) { + // Network error - continue with local data if available + if (loadedFromLocal) { + console.log(`Offline mode: using local data from IndexedDB`) + } else { + console.error("Error loading from server (offline?):", error) + } + } + + // Verify final document state + const finalDoc = handle.doc() as any + const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 + const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 + console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`) + + setHandle(handle) + setIsLoading(false) } catch (error) { console.error("Error initializing Automerge handle:", error) if (mounted) { diff --git a/src/components/HolonBrowser.tsx b/src/components/HolonBrowser.tsx index bb1cd9c..5e016cf 100644 --- a/src/components/HolonBrowser.tsx +++ b/src/components/HolonBrowser.tsx @@ -51,7 +51,7 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false try { // Validate that the holonId is a valid H3 index if (!h3.isValidCell(holonId)) { - throw new Error('Invalid H3 cell ID') + throw new Error('Invalid H3 Cell ID. Holon IDs must be valid H3 geospatial cell identifiers (e.g., 872a1070bffffff)') } // Get holon information @@ -210,7 +210,7 @@ export function HolonBrowser({ isOpen, onClose, onSelectHolon, shapeMode = false value={holonId} onChange={(e) => setHolonId(e.target.value)} onKeyDown={handleKeyDown} - placeholder="e.g., 1002848305066" + placeholder="e.g., 872a1070bffffff" className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 z-[10001] relative" disabled={isLoading} style={{ zIndex: 10001 }} diff --git a/src/css/style.css b/src/css/style.css index 3aeef03..a49b4f5 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -1006,11 +1006,11 @@ input[type="submit"], Toolbar and Share Zone Alignment ======================================== */ -/* Position the share zone (people menu) to not overlap with custom toolbar */ +/* Position the share zone (people menu) in the top right */ .tlui-share-zone { position: fixed !important; - top: 4px !important; - right: 8px !important; + top: 8px !important; + right: 12px !important; z-index: 99998 !important; display: flex !important; align-items: center !important; @@ -1021,28 +1021,44 @@ input[type="submit"], display: flex; align-items: center; gap: 4px; - padding: 4px 8px; - background: rgba(255, 255, 255, 0.9); - border-radius: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + padding: 6px 10px; + background: rgba(255, 255, 255, 0.95); + border-radius: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + backdrop-filter: blur(8px); } html.dark .custom-people-menu { - background: rgba(45, 55, 72, 0.9); + background: rgba(45, 55, 72, 0.95); +} + +/* People dropdown styling */ +.people-dropdown { + backdrop-filter: blur(8px); +} + +html.dark .people-dropdown { + background: rgba(30, 30, 30, 0.98) !important; } /* Ensure custom toolbar buttons don't overlap with share zone */ +/* Position to the left of the people menu with adequate spacing */ .toolbar-container { position: fixed !important; - top: 4px !important; - /* Adjust right position to leave room for people menu (about 80px) */ - right: 90px !important; + top: 8px !important; + /* Leave enough room for people menu - accounts for multiple users */ + right: 140px !important; z-index: 99999 !important; display: flex !important; - gap: 6px !important; + gap: 8px !important; align-items: center !important; } +/* Move the tldraw style panel (color picker) below the top-right UI */ +.tlui-style-panel__wrapper { + top: 52px !important; +} + /* ======================================== Unified Toolbar Button Styles ======================================== */ @@ -1052,14 +1068,14 @@ html.dark .custom-people-menu { align-items: center; justify-content: center; gap: 6px; - padding: 4px 10px; - height: 28px; - min-height: 28px; + padding: 6px 12px; + height: 32px; + min-height: 32px; background: var(--tool-bg); color: var(--tool-text); border: 1px solid var(--tool-border); - border-radius: 6px; - font-size: 12px; + border-radius: 16px; + font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; @@ -1075,10 +1091,13 @@ html.dark .custom-people-menu { .toolbar-btn svg { flex-shrink: 0; + width: 16px; + height: 16px; } .profile-btn { - padding: 4px 8px; + padding: 6px 10px; + height: 32px; } .profile-username { @@ -1466,16 +1485,16 @@ html.dark .custom-people-menu { /* Responsive adjustments */ @media (max-width: 768px) { .toolbar-container { - right: 70px !important; - gap: 4px !important; + right: 90px !important; + gap: 6px !important; } .tlui-share-zone { - right: 4px !important; + right: 8px !important; } .custom-people-menu { - padding: 2px 4px; + padding: 4px 6px; gap: 2px; } @@ -1485,6 +1504,14 @@ html.dark .custom-people-menu { .toolbar-btn { padding: 4px 8px; + height: 28px; + min-height: 28px; + font-size: 12px; + } + + .toolbar-btn svg { + width: 14px; + height: 14px; } .settings-modal { diff --git a/src/propagators/ScopedPropagators.ts b/src/propagators/ScopedPropagators.ts index 03cccde..86a2309 100644 --- a/src/propagators/ScopedPropagators.ts +++ b/src/propagators/ScopedPropagators.ts @@ -246,7 +246,17 @@ export class ClickPropagator extends Propagator { eventHandler(event: any): void { if (event.type !== 'pointer' || event.name !== 'pointer_down') return; - const shapeAtPoint = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'geo' }); + + // Wrap in try-catch to handle geometry errors from shapes with invalid paths + let shapeAtPoint; + try { + shapeAtPoint = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'geo' }); + } catch (error) { + // Some shapes may have invalid geometry (e.g., empty paths) that cause nearestPoint to fail + console.warn('ClickPropagator: Error getting shape at point:', error); + return; + } + if (!shapeAtPoint) return if (!this.listenerShapes.has(shapeAtPoint.id)) return const edgesFromHovered = getArrowsFromShape(this.editor, shapeAtPoint.id) diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 5533706..02ed3a3 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -1,7 +1,7 @@ import { useAutomergeSync } from "@/automerge/useAutomergeSync" import { AutomergeHandleProvider } from "@/context/AutomergeHandleContext" import { useMemo, useEffect, useState, useRef } from "react" -import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences, createShapeId } from "tldraw" +import { Tldraw, Editor, TLShapeId, TLRecord, useTldrawUser, TLUserPreferences } from "tldraw" import { useParams } from "react-router-dom" import { ChatBoxTool } from "@/tools/ChatBoxTool" import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" @@ -963,47 +963,7 @@ export function Board() { initializeGlobalCollections(editor, collections) // Note: User presence is configured through the useAutomergeSync hook above // The authenticated username should appear in the people section - - // Auto-create Mycelial Intelligence shape on page load if not present - // Use a flag to ensure this only runs once per session - const miCreatedKey = `mi-created-${roomId}` - const alreadyCreatedThisSession = sessionStorage.getItem(miCreatedKey) - - if (!alreadyCreatedThisSession) { - setTimeout(() => { - const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence') - if (!existingMI) { - const viewport = editor.getViewportScreenBounds() - const miWidth = 520 - const screenCenter = { - x: viewport.x + (viewport.w / 2) - (miWidth / 2), - y: viewport.y + 20, // 20px from top - } - const pagePoint = editor.screenToPage(screenCenter) - - editor.createShape({ - id: createShapeId(), - type: 'MycelialIntelligence', - x: pagePoint.x, - y: pagePoint.y, - props: { - w: miWidth, - h: 52, - prompt: '', - response: '', - isLoading: false, - isListening: false, - isExpanded: false, - conversationHistory: [], - pinnedToView: true, - indexingProgress: 0, - isIndexing: false, - }, - }) - } - sessionStorage.setItem(miCreatedKey, 'true') - }, 500) // Small delay to ensure editor is ready - } + // MycelialIntelligence is now a permanent UI bar - no shape creation needed }} > diff --git a/src/shapes/HolonShapeUtil.tsx b/src/shapes/HolonShapeUtil.tsx index 4cbf465..5f61be7 100644 --- a/src/shapes/HolonShapeUtil.tsx +++ b/src/shapes/HolonShapeUtil.tsx @@ -3,8 +3,8 @@ import { HTMLContainer, TLBaseShape, } from "tldraw" -import React, { useState, useRef, useEffect, useMemo, useCallback } from "react" -import { holosphereService, HoloSphereService, HolonData, HolonLens, HolonConnection } from "@/lib/HoloSphereService" +import React, { useState, useRef, useEffect, useCallback } from "react" +import { holosphereService, HoloSphereService, HolonConnection } from "@/lib/HoloSphereService" import * as h3 from 'h3-js' import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" import { usePinnedToView } from "../hooks/usePinnedToView" @@ -103,7 +103,6 @@ export class HolonShape extends BaseBoxShapeUtil { const [isHovering, setIsHovering] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isMinimized, setIsMinimized] = useState(false) - const [lenses, setLenses] = useState([]) const [currentData, setCurrentData] = useState(null) const [error, setError] = useState(null) @@ -242,15 +241,47 @@ export class HolonShape extends BaseBoxShapeUtil { }) } + // Validate if input is a valid H3 cell ID + const isValidH3Cell = (id: string): boolean => { + if (!id || id.trim() === '') return false + try { + return h3.isValidCell(id.trim()) + } catch { + return false + } + } + const handleConnect = async () => { const trimmedHolonId = holonId?.trim() || '' if (!trimmedHolonId) { + setError('Please enter a Holon ID') + return + } + + // Validate H3 cell ID + if (!isValidH3Cell(trimmedHolonId)) { + setError('Invalid H3 Cell ID. Holon IDs must be valid H3 geospatial cell identifiers (e.g., 872a1070bffffff)') return } console.log('🔌 Connecting to Holon:', trimmedHolonId) + setError(null) - // Update the shape to mark as connected with trimmed ID + // Extract H3 cell info (coordinates and resolution) + let cellLatitude = latitude + let cellLongitude = longitude + let cellResolution = resolution + try { + const [lat, lng] = h3.cellToLatLng(trimmedHolonId) + cellLatitude = lat + cellLongitude = lng + cellResolution = h3.getResolution(trimmedHolonId) + console.log(`📍 H3 Cell Info: lat=${lat}, lng=${lng}, resolution=${cellResolution}`) + } catch (e) { + console.warn('Could not extract H3 cell coordinates:', e) + } + + // Update the shape to mark as connected with trimmed ID and H3 info this.editor.updateShape({ id: shape.id, type: "Holon", @@ -258,6 +289,9 @@ export class HolonShape extends BaseBoxShapeUtil { ...shape.props, isConnected: true, holonId: trimmedHolonId, + latitude: cellLatitude, + longitude: cellLongitude, + resolution: cellResolution, }, }) @@ -723,16 +757,64 @@ export class HolonShape extends BaseBoxShapeUtil { boxSizing: 'border-box', minHeight: 0 }}> -
- Enter your HolonID to connect to the Holosphere + Enter an H3 Cell ID to connect to the Holosphere +
+
+ H3 Cell IDs are hexagonal geospatial identifiers (e.g., 872a1070bffffff) +
+ {/* Quick generate button */} +
+
{ handleConnect() } }} - placeholder="1002848305066" + placeholder="872a1070bffffff" style={{ flex: 1, height: '48px', @@ -820,9 +902,22 @@ export class HolonShape extends BaseBoxShapeUtil { Connect to the Holosphere
-
+ {error} +
+ )} +
{ }} onWheel={handleWheel} > + {/* H3 Cell Information Header */} + {isConnected && ( +
+
+
+ Resolution:{' '} + + {resolutionInfo.name} (Level {resolution}) + +
+
+ Coordinates:{' '} + + {latitude.toFixed(4)}, {longitude.toFixed(4)} + +
+
+
+ {resolutionInfo.description} +
+
+ )} + {/* Display all data from all lenses */} {isConnected && data && Object.keys(data).length > 0 && ( -
+
- 📊 Holon Data ({Object.keys(data).length} categor{Object.keys(data).length !== 1 ? 'ies' : 'y'}) + Data Lenses ({Object.keys(data).length} categor{Object.keys(data).length !== 1 ? 'ies' : 'y'})
{Object.entries(data).map(([lensName, lensData]) => ( @@ -867,39 +996,106 @@ export class HolonShape extends BaseBoxShapeUtil { fontSize: '10px', fontWeight: 'bold', color: '#2196F3', - marginBottom: '6px' + marginBottom: '6px', + display: 'flex', + alignItems: 'center', + gap: '6px' }}> - {getCategoryIcon(lensName)} {getCategoryDisplayName(lensName)} + {getCategoryIcon(lensName)} + {getCategoryDisplayName(lensName)} + {lensData && typeof lensData === 'object' && ( + + ({Object.keys(lensData).length} items) + + )}
{lensData && typeof lensData === 'object' ? ( Object.entries(lensData).length > 0 ? ( -
- {Object.entries(lensData).map(([key, value]) => ( -
- + {Object.entries(lensData).map(([key, value]) => { + // Render items - HoloSphere items have an 'id' field + const item = value as Record + const isHolonItem = item && typeof item === 'object' && 'id' in item + + return ( +
- {key}: - - - {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} - -
- ))} + {isHolonItem ? ( + // Render as a holon item with structured display +
+
+ {item.name || item.title || item.id} + {item.timestamp && ( + + {new Date(item.timestamp).toLocaleDateString()} + + )} +
+ {item.description && ( +
+ {item.description} +
+ )} + {item.content && ( +
+ {String(item.content).slice(0, 100)} + {String(item.content).length > 100 && '...'} +
+ )} + {/* Show other relevant fields */} + {Object.entries(item) + .filter(([k]) => !['id', 'name', 'title', 'description', 'content', 'timestamp', '_'].includes(k)) + .slice(0, 3) + .map(([k, v]) => ( +
+ {k}: {String(v).slice(0, 50)} +
+ )) + } +
+ ) : ( + // Render as key-value pair +
+ + {key}: + + + {typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)} + +
+ )} +
+ ) + })}
) : (
No data in this lens
@@ -915,18 +1111,25 @@ export class HolonShape extends BaseBoxShapeUtil { {isConnected && (!data || Object.keys(data).length === 0) && (
-
📭 No data found in this holon
-
- Categories checked: Active Users, Users, Rankings, Tasks, Progress, Events, Activities, Items, Shopping, Proposals, Offers, Checklists, Roles +
+ No data found in this holon
+
+ This H3 cell may not have any data stored yet. You can add data using the + button above. +
+ {isLoading && ( +
+ Loading data from GunDB... +
+ )}
)}
diff --git a/src/shapes/ImageGenShapeUtil.tsx b/src/shapes/ImageGenShapeUtil.tsx index de97979..9882a26 100644 --- a/src/shapes/ImageGenShapeUtil.tsx +++ b/src/shapes/ImageGenShapeUtil.tsx @@ -289,25 +289,28 @@ export class ImageGenShape extends BaseBoxShapeUtil { } getGeometry(shape: IImageGen): Geometry2d { + // Ensure minimum dimensions for proper hit testing return new Rectangle2d({ - width: shape.props.w, - height: shape.props.h, + width: Math.max(shape.props.w, 1), + height: Math.max(shape.props.h, 1), isFilled: true, }) } component(shape: IImageGen) { + // Capture editor reference to avoid stale 'this' during drag operations + const editor = this.editor const [isHovering, setIsHovering] = useState(false) - const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + const isSelected = editor.getSelectedShapeIds().includes(shape.id) const generateImage = async (prompt: string) => { console.log("🎨 ImageGen: Generating image with prompt:", prompt) // Clear any previous errors - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: "ImageGen", - props: { + props: { error: null, isLoading: true, imageUrl: null @@ -333,7 +336,7 @@ export class ImageGenShape extends BaseBoxShapeUtil { console.log("✅ ImageGen: Mock image generated:", mockImageUrl) - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: "ImageGen", props: { @@ -413,7 +416,7 @@ export class ImageGenShape extends BaseBoxShapeUtil { if (imageUrl) { console.log('✅ ImageGen: Image generated successfully') - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: "ImageGen", props: { @@ -461,10 +464,10 @@ export class ImageGenShape extends BaseBoxShapeUtil { } } - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: "ImageGen", - props: { + props: { isLoading: false, error: userFriendlyError }, @@ -475,7 +478,7 @@ export class ImageGenShape extends BaseBoxShapeUtil { const handleGenerate = () => { if (shape.props.prompt.trim() && !shape.props.isLoading) { generateImage(shape.props.prompt) - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: "ImageGen", props: { prompt: "" }, @@ -522,7 +525,7 @@ export class ImageGenShape extends BaseBoxShapeUtil { {shape.props.error} + ))} +
+
+ + {/* Actions Section */} +
+

+ Actions +

+
+ {customActionShortcuts.map(item => ( + + ))} +
+
+ + {/* Footer hint */} +
+

+ Press ? for full keyboard shortcuts dialog +

+
+
+ + {/* CSS Animations */} + + + ) +} diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index 417eb91..4eb03ba 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -242,7 +242,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) { - + {/* MycelialIntelligence moved to permanent UI bar */} {/* Collections Group */} diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx index 552f1ed..aab9b53 100644 --- a/src/ui/CustomToolbar.tsx +++ b/src/ui/CustomToolbar.tsx @@ -24,7 +24,8 @@ const getDarkMode = (): boolean => { if (stored !== null) { return stored === 'true' } - return window.matchMedia('(prefers-color-scheme: dark)').matches + // Default to light mode instead of system preference + return false } const setDarkMode = (isDark: boolean) => { diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 66d9b08..3e800de 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -1,8 +1,10 @@ +import React from "react" import { CustomMainMenu } from "./CustomMainMenu" import { CustomToolbar } from "./CustomToolbar" import { CustomContextMenu } from "./CustomContextMenu" import { FocusLockIndicator } from "./FocusLockIndicator" import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar" +import { CommandPalette } from "./CommandPalette" import { DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, @@ -18,6 +20,7 @@ import { SlidesPanel } from "@/slides/SlidesPanel" // Custom People Menu component for showing connected users function CustomPeopleMenu() { const editor = useEditor() + const [showDropdown, setShowDropdown] = React.useState(false) // Get current user info const myUserColor = useValue('myColor', () => editor.user.getColor(), [editor]) @@ -26,49 +29,189 @@ function CustomPeopleMenu() { // Get all collaborators (other users in the session) const collaborators = useValue('collaborators', () => editor.getCollaborators(), [editor]) - return ( -
- {/* Current user avatar */} -
+ const totalUsers = collaborators.length + 1 - {/* Other users */} - {collaborators.map((presence) => ( + return ( +
+ {/* Clickable avatar stack */} + + + {/* Dropdown with user names */} + {showDropdown && ( +
+
+ Participants ({totalUsers}) +
+ + {/* Current user */} +
+
+ {myUserName.charAt(0).toUpperCase()} +
+ + {myUserName} (you) + +
+ + {/* Other users */} + {collaborators.map((presence) => ( +
+
+ {(presence.userName || 'A').charAt(0).toUpperCase()} +
+ + {presence.userName || 'Anonymous'} + +
+ ))} +
+ )} + + {/* Click outside to close */} + {showDropdown && ( +
setShowDropdown(false)} + /> )}
) @@ -89,6 +232,7 @@ function CustomInFrontOfCanvas() { <> + ) } diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx index db14bad..4c3f3b1 100644 --- a/src/ui/overrides.tsx +++ b/src/ui/overrides.tsx @@ -99,7 +99,7 @@ export const overrides: TLUiOverrides = { id: "VideoChat", icon: "video", label: "Video Chat", - kbd: "alt+v", + kbd: "ctrl+shift+v", readonlyOk: true, type: "VideoChat", onSelect: () => editor.setCurrentTool("VideoChat"), @@ -108,7 +108,7 @@ export const overrides: TLUiOverrides = { id: "ChatBox", icon: "chat", label: "Chat", - kbd: "alt+c", + kbd: "ctrl+shift+c", readonlyOk: true, type: "ChatBox", onSelect: () => editor.setCurrentTool("ChatBox"), @@ -117,7 +117,7 @@ export const overrides: TLUiOverrides = { id: "Embed", icon: "embed", label: "Embed", - kbd: "alt+e", + kbd: "ctrl+shift+e", readonlyOk: true, type: "Embed", onSelect: () => editor.setCurrentTool("Embed"), @@ -126,7 +126,7 @@ export const overrides: TLUiOverrides = { id: "Slide", icon: "slides", label: "Slide", - kbd: "alt+s", + kbd: "ctrl+shift+s", type: "Slide", readonlyOk: true, onSelect: () => { @@ -137,7 +137,7 @@ export const overrides: TLUiOverrides = { id: "Markdown", icon: "markdown", label: "Markdown", - kbd: "alt+m", + kbd: "ctrl+shift+m", readonlyOk: true, type: "Markdown", onSelect: () => editor.setCurrentTool("Markdown"), @@ -147,7 +147,7 @@ export const overrides: TLUiOverrides = { icon: "rectangle", label: "Mycrozine Template", type: "MycrozineTemplate", - kbd: "alt+z", + kbd: "ctrl+shift+z", readonlyOk: true, onSelect: () => editor.setCurrentTool("MycrozineTemplate"), }, @@ -156,7 +156,7 @@ export const overrides: TLUiOverrides = { icon: "prompt", label: "LLM Prompt", type: "Prompt", - kbd: "alt+l", + kbd: "ctrl+shift+l", readonlyOk: true, onSelect: () => editor.setCurrentTool("Prompt"), }, @@ -172,7 +172,7 @@ export const overrides: TLUiOverrides = { id: "ObsidianNote", icon: "file-text", label: "Obsidian Note", - kbd: "alt+o", + kbd: "ctrl+shift+o", readonlyOk: true, type: "ObsNote", onSelect: () => editor.setCurrentTool("ObsidianNote"), @@ -181,7 +181,7 @@ export const overrides: TLUiOverrides = { id: "Transcription", icon: "microphone", label: "Transcription", - kbd: "alt+t", + kbd: "ctrl+shift+t", readonlyOk: true, type: "Transcription", onSelect: () => editor.setCurrentTool("Transcription"), @@ -190,7 +190,7 @@ export const overrides: TLUiOverrides = { id: "Holon", icon: "circle", label: "Holon", - kbd: "alt+h", + kbd: "ctrl+shift+h", readonlyOk: true, type: "Holon", onSelect: () => editor.setCurrentTool("Holon"), @@ -199,7 +199,7 @@ export const overrides: TLUiOverrides = { id: "fathom-meetings", icon: "calendar", label: "Fathom Meetings", - kbd: "alt+f", + kbd: "ctrl+shift+f", readonlyOk: true, // Removed type property to prevent automatic shape creation // Shape creation is handled manually in FathomMeetingsTool.onPointerDown @@ -209,7 +209,7 @@ export const overrides: TLUiOverrides = { id: "ImageGen", icon: "image", label: "Image Generation", - kbd: "alt+i", + kbd: "ctrl+shift+i", readonlyOk: true, type: "ImageGen", onSelect: () => editor.setCurrentTool("ImageGen"), @@ -218,7 +218,7 @@ export const overrides: TLUiOverrides = { id: "VideoGen", icon: "video", label: "Video Generation", - kbd: "alt+v", + kbd: "ctrl+shift+g", readonlyOk: true, onSelect: () => editor.setCurrentTool("VideoGen"), }, @@ -226,47 +226,11 @@ export const overrides: TLUiOverrides = { id: "Multmux", icon: "terminal", label: "Terminal", - kbd: "alt+m", + kbd: "ctrl+shift+k", readonlyOk: true, onSelect: () => editor.setCurrentTool("Multmux"), }, - MycelialIntelligence: { - id: "MycelialIntelligence", - icon: "chat", - label: "Mycelial Intelligence", - kbd: "ctrl+shift+m", - readonlyOk: true, - type: "MycelialIntelligence", - onSelect: () => { - // Spawn the MI shape at top center of viewport - const viewport = editor.getViewportPageBounds() - const shapeWidth = 600 - const shapeHeight = 60 - - // Calculate center top position - const x = viewport.x + (viewport.w / 2) - (shapeWidth / 2) - const y = viewport.y + 20 - - // Check if MI already exists on canvas - if so, select it - const existingMI = editor.getCurrentPageShapes().find(s => s.type === 'MycelialIntelligence') - if (existingMI) { - editor.setSelectedShapes([existingMI.id]) - return - } - - // Create the shape - editor.createShape({ - type: 'MycelialIntelligence', - x, - y, - props: { - w: shapeWidth, - h: shapeHeight, - pinnedToView: true, - } - }) - }, - }, + // MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx) hand: { ...tools.hand, onDoubleClick: (info: any) => { @@ -327,14 +291,14 @@ export const overrides: TLUiOverrides = { copyLinkToCurrentView: { id: "copy-link-to-current-view", label: "Copy Link to Current View", - kbd: "alt+c", + kbd: "ctrl+alt+c", onSelect: () => copyLinkToCurrentView(editor), readonlyOk: true, }, copyFocusLink: { id: "copy-focus-link", label: "Copy Focus Link (Locked View)", - kbd: "alt+shift+f", + kbd: "ctrl+alt+f", onSelect: () => { if (editor.getSelectedShapeIds().length > 0) { copyFocusLink(editor) @@ -352,7 +316,7 @@ export const overrides: TLUiOverrides = { revertCamera: { id: "revert-camera", label: "Revert Camera", - kbd: "alt+b", + kbd: "ctrl+alt+b", onSelect: () => { if (cameraHistory.length > 0) { revertCamera(editor) @@ -384,7 +348,7 @@ export const overrides: TLUiOverrides = { saveToPdf: { id: "save-to-pdf", label: "Save Selection as PDF", - kbd: "alt+p", + kbd: "ctrl+alt+p", onSelect: () => { if (editor.getSelectedShapeIds().length > 0) { saveToPdf(editor) @@ -563,7 +527,7 @@ export const overrides: TLUiOverrides = { llm: { id: "llm", label: "Run LLM Prompt", - kbd: "alt+g", + kbd: "ctrl+alt+r", readonlyOk: true, onSelect: () => { diff --git a/src/utils/llmUtils.ts b/src/utils/llmUtils.ts index 0af5c91..db83b01 100644 --- a/src/utils/llmUtils.ts +++ b/src/utils/llmUtils.ts @@ -1,7 +1,7 @@ import OpenAI from "openai"; import Anthropic from "@anthropic-ai/sdk"; import { makeRealSettings, AI_PERSONALITIES } from "@/lib/settings"; -import { getRunPodConfig, getOllamaConfig } from "@/lib/clientConfig"; +import { getRunPodConfig, getRunPodTextConfig, getOllamaConfig } from "@/lib/clientConfig"; export async function llm( userPrompt: string, @@ -183,17 +183,29 @@ function getAvailableProviders(availableKeys: Record, settings: }); } - // PRIORITY 1: Check for RunPod configuration from environment variables - // RunPod is used as fallback when Ollama is not available - const runpodConfig = getRunPodConfig(); - if (runpodConfig && runpodConfig.apiKey && runpodConfig.endpointId) { - console.log('🔑 Found RunPod configuration from environment variables'); + // PRIORITY 1: Check for RunPod TEXT configuration from environment variables + // RunPod vLLM text endpoint is used as fallback when Ollama is not available + const runpodTextConfig = getRunPodTextConfig(); + if (runpodTextConfig && runpodTextConfig.apiKey && runpodTextConfig.endpointId) { + console.log('🔑 Found RunPod TEXT endpoint configuration from environment variables'); providers.push({ provider: 'runpod', - apiKey: runpodConfig.apiKey, - endpointId: runpodConfig.endpointId, - model: 'default' // RunPod doesn't use model selection in the same way + apiKey: runpodTextConfig.apiKey, + endpointId: runpodTextConfig.endpointId, + model: 'default' // RunPod vLLM endpoint }); + } else { + // Fallback to generic RunPod config if text endpoint not configured + const runpodConfig = getRunPodConfig(); + if (runpodConfig && runpodConfig.apiKey && runpodConfig.endpointId) { + console.log('🔑 Found RunPod configuration from environment variables (generic endpoint)'); + providers.push({ + provider: 'runpod', + apiKey: runpodConfig.apiKey, + endpointId: runpodConfig.endpointId, + model: 'default' + }); + } } // PRIORITY 2: Then add user-configured keys (they will be tried after RunPod) @@ -525,11 +537,12 @@ async function callProviderAPI( const systemPrompt = settings ? getSystemPrompt(settings) : 'You are a helpful assistant.'; if (provider === 'ollama') { - // Ollama API integration - uses OpenAI-compatible API format + // Ollama API integration via AI Orchestrator + // The orchestrator provides /api/chat endpoint that routes to local Ollama const ollamaConfig = getOllamaConfig(); const baseUrl = (settings as any)?.baseUrl || ollamaConfig?.url || 'http://localhost:11434'; - console.log(`🦙 Ollama API: Using ${baseUrl}/v1/chat/completions with model ${model}`); + console.log(`🦙 Ollama API: Using ${baseUrl}/api/chat with model ${model}`); const messages = []; if (systemPrompt) { @@ -538,7 +551,8 @@ async function callProviderAPI( messages.push({ role: 'user', content: userPrompt }); try { - const response = await fetch(`${baseUrl}/v1/chat/completions`, { + // Use the AI Orchestrator's /api/chat endpoint + const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -546,7 +560,7 @@ async function callProviderAPI( body: JSON.stringify({ model: model, messages: messages, - stream: true, // Enable streaming for better UX + priority: 'low', // Use free Ollama }) }); @@ -556,36 +570,27 @@ async function callProviderAPI( throw new Error(`Ollama API error: ${response.status} - ${errorText}`); } - // Handle streaming response - const reader = response.body?.getReader(); - if (!reader) { - throw new Error('No response body from Ollama'); + const data = await response.json(); + console.log('📥 Ollama API: Response received:', JSON.stringify(data, null, 2).substring(0, 500)); + + // Extract response from AI Orchestrator format + let responseText = ''; + if (data.message?.content) { + responseText = data.message.content; + } else if (data.response) { + responseText = data.response; + } else if (typeof data === 'string') { + responseText = data; } - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n').filter(line => line.trim() !== ''); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6); - if (data === '[DONE]') continue; - - try { - const parsed = JSON.parse(data); - const content = parsed.choices?.[0]?.delta?.content || ''; - if (content) { - partial += content; - onToken(partial, false); - } - } catch (e) { - // Skip malformed JSON chunks - } + if (responseText) { + // Stream the response character by character for UX + for (let i = 0; i < responseText.length; i++) { + partial += responseText[i]; + onToken(partial, false); + // Small delay to simulate streaming + if (i % 10 === 0) { + await new Promise(resolve => setTimeout(resolve, 5)); } } }