feat: fix Holon shape H3 validation + offline persistence + geometry error handling
Holon Shape Improvements: - Add H3 cell ID validation before connecting to Holosphere - Extract coordinates and resolution from H3 cell IDs automatically - Improve data rendering with proper lens/item structure display - Add "Generate H3 Cell" button for quick cell ID creation - Update placeholders and error messages for H3 format - Fix HolonBrowser validation and placeholder text Geometry Error Fix: - Add try-catch in ClickPropagator.eventHandler for shapes with invalid paths - Add try-catch in CmdK for getShapesAtPoint geometry errors - Prevents "No nearest point found" crashes from corrupted draw/line shapes Offline Persistence: - Add IndexedDB storage adapter for Automerge documents - Implement document ID mapping for room persistence - Merge local and server data on reconnection - Support offline editing with automatic sync Other Changes: - Update .env.example with Ollama and RunPod configuration - Add multmux Docker configuration files - UI styling improvements for toolbar and share zone - Remove auto-creation of MycelialIntelligence shape (now permanent UI bar) - Various shape utility minor fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5cd36c3d3c
commit
33ff5216cc
17
.env.example
17
.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'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
packages/*/node_modules
|
||||
packages/*/dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
infrastructure/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
src/CmdK.tsx
10
src/CmdK.tsx
|
|
@ -66,11 +66,19 @@ export const CmdK = () => {
|
|||
)
|
||||
|
||||
const selected = editor.getSelectedShapeIds()
|
||||
const inView = editor
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<IDBDatabase> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DocumentMapping[]> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,17 +255,59 @@ 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<TLStoreSnapshot>()
|
||||
if (!mounted) return
|
||||
|
||||
// Wait for the handle to be ready
|
||||
let handle: DocHandle<TLStoreSnapshot>
|
||||
let loadedFromLocal = false
|
||||
|
||||
// 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 {
|
||||
// Try to find the existing document in the repo (loads from IndexedDB)
|
||||
// repo.find() returns a Promise<DocHandle>
|
||||
const foundHandle = await repo.find<TLStoreSnapshot>(storedDocumentId as AutomergeUrl)
|
||||
await foundHandle.whenReady()
|
||||
handle = foundHandle
|
||||
|
||||
// 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
|
||||
|
||||
if (localRecordCount > 0) {
|
||||
console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
||||
loadedFromLocal = true
|
||||
} else {
|
||||
console.log(`Document found in IndexedDB but is empty, will load from server`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load document ${storedDocumentId} from IndexedDB:`, error)
|
||||
// Fall through to create a new document
|
||||
}
|
||||
}
|
||||
|
||||
// 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<TLStoreSnapshot>()
|
||||
await handle.whenReady()
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
|
|
@ -266,44 +315,62 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||
|
||||
// Document loaded from server
|
||||
// Get current local state
|
||||
const localDoc = handle.doc()
|
||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||
|
||||
// Initialize the Automerge document with server data
|
||||
// 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 = {}
|
||||
}
|
||||
// Copy all records from server document
|
||||
// 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Initialized Automerge document from server
|
||||
} else {
|
||||
// Server document is empty - starting fresh
|
||||
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 - starting fresh
|
||||
// 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.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
console.log(`No document found on server - starting fresh`)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error loading initial document from server:", error)
|
||||
// Continue anyway - user can still create new content
|
||||
// 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'})`)
|
||||
|
||||
// Automerge handle initialized and ready
|
||||
setHandle(handle)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing Automerge handle:", error)
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
|
|
|
|||
|
|
@ -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<IHolon> {
|
|||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [lenses, setLenses] = useState<HolonLens[]>([])
|
||||
const [currentData, setCurrentData] = useState<any>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -242,15 +241,47 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
})
|
||||
}
|
||||
|
||||
// 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<IHolon>({
|
||||
id: shape.id,
|
||||
type: "Holon",
|
||||
|
|
@ -258,6 +289,9 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
...shape.props,
|
||||
isConnected: true,
|
||||
holonId: trimmedHolonId,
|
||||
latitude: cellLatitude,
|
||||
longitude: cellLongitude,
|
||||
resolution: cellResolution,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -732,7 +766,55 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
lineHeight: '1.5',
|
||||
width: '100%'
|
||||
}}>
|
||||
Enter your HolonID to connect to the Holosphere
|
||||
Enter an H3 Cell ID to connect to the Holosphere
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
H3 Cell IDs are hexagonal geospatial identifiers (e.g., 872a1070bffffff)
|
||||
</div>
|
||||
{/* Quick generate button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Generate H3 cell from default coordinates
|
||||
try {
|
||||
const newCellId = h3.latLngToCell(latitude, longitude, resolution)
|
||||
handleHolonIdChange(newCellId)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError('Failed to generate H3 cell ID')
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '11px',
|
||||
color: '#4b5563',
|
||||
backgroundColor: '#f3f4f6',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#e5e7eb'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6'
|
||||
}}
|
||||
>
|
||||
Generate H3 Cell for current location (NYC)
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
|
@ -753,7 +835,7 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
handleConnect()
|
||||
}
|
||||
}}
|
||||
placeholder="1002848305066"
|
||||
placeholder="872a1070bffffff"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '48px',
|
||||
|
|
@ -820,6 +902,19 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
Connect to the Holosphere
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#dc2626',
|
||||
textAlign: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fecaca'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
|
|
@ -847,18 +942,52 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* H3 Cell Information Header */}
|
||||
{isConnected && (
|
||||
<div style={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '1px solid #86efac',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '8px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ color: '#666', fontWeight: '500' }}>Resolution:</span>{' '}
|
||||
<span style={{ color: '#15803d', fontWeight: '600' }}>
|
||||
{resolutionInfo.name} (Level {resolution})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: '#666', fontWeight: '500' }}>Coordinates:</span>{' '}
|
||||
<span style={{ fontFamily: 'monospace', color: '#333' }}>
|
||||
{latitude.toFixed(4)}, {longitude.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '6px' }}>
|
||||
{resolutionInfo.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display all data from all lenses */}
|
||||
{isConnected && data && Object.keys(data).length > 0 && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: '8px',
|
||||
borderBottom: '2px solid #4CAF50',
|
||||
borderBottom: '2px solid #22c55e',
|
||||
paddingBottom: '4px'
|
||||
}}>
|
||||
📊 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'})
|
||||
</div>
|
||||
|
||||
{Object.entries(data).map(([lensName, lensData]) => (
|
||||
|
|
@ -867,27 +996,91 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
fontSize: '10px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2196F3',
|
||||
marginBottom: '6px'
|
||||
marginBottom: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
{getCategoryIcon(lensName)} {getCategoryDisplayName(lensName)}
|
||||
<span>{getCategoryIcon(lensName)}</span>
|
||||
<span>{getCategoryDisplayName(lensName)}</span>
|
||||
{lensData && typeof lensData === 'object' && (
|
||||
<span style={{ color: '#888', fontWeight: 'normal' }}>
|
||||
({Object.keys(lensData).length} items)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
backgroundColor: '#f9f9f9',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e0e0e0',
|
||||
fontSize: '9px'
|
||||
fontSize: '9px',
|
||||
maxHeight: '150px',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{lensData && typeof lensData === 'object' ? (
|
||||
Object.entries(lensData).length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{Object.entries(lensData).map(([key, value]) => (
|
||||
<div key={key} style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{Object.entries(lensData).map(([key, value]) => {
|
||||
// Render items - HoloSphere items have an 'id' field
|
||||
const item = value as Record<string, any>
|
||||
const isHolonItem = item && typeof item === 'object' && 'id' in item
|
||||
|
||||
return (
|
||||
<div key={key} style={{
|
||||
backgroundColor: '#fff',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{isHolonItem ? (
|
||||
// Render as a holon item with structured display
|
||||
<div>
|
||||
<div style={{
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span>{item.name || item.title || item.id}</span>
|
||||
{item.timestamp && (
|
||||
<span style={{ fontSize: '8px', color: '#9ca3af', fontWeight: 'normal' }}>
|
||||
{new Date(item.timestamp).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && (
|
||||
<div style={{ color: '#6b7280', fontSize: '9px' }}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{item.content && (
|
||||
<div style={{ color: '#374151', fontSize: '9px', marginTop: '2px' }}>
|
||||
{String(item.content).slice(0, 100)}
|
||||
{String(item.content).length > 100 && '...'}
|
||||
</div>
|
||||
)}
|
||||
{/* Show other relevant fields */}
|
||||
{Object.entries(item)
|
||||
.filter(([k]) => !['id', 'name', 'title', 'description', 'content', 'timestamp', '_'].includes(k))
|
||||
.slice(0, 3)
|
||||
.map(([k, v]) => (
|
||||
<div key={k} style={{ fontSize: '8px', color: '#9ca3af', marginTop: '2px' }}>
|
||||
<span style={{ fontWeight: '500' }}>{k}:</span> {String(v).slice(0, 50)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
// Render as key-value pair
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
minWidth: '80px',
|
||||
fontFamily: 'monospace'
|
||||
minWidth: '60px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px'
|
||||
}}>
|
||||
{key}:
|
||||
</span>
|
||||
|
|
@ -899,7 +1092,10 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#999', fontStyle: 'italic' }}>No data in this lens</div>
|
||||
|
|
@ -915,18 +1111,25 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
|
||||
{isConnected && (!data || Object.keys(data).length === 0) && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fefce8',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #fde047',
|
||||
color: '#854d0e',
|
||||
fontSize: '11px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ marginBottom: '8px' }}>📭 No data found in this holon</div>
|
||||
<div style={{ fontSize: '9px' }}>
|
||||
Categories checked: Active Users, Users, Rankings, Tasks, Progress, Events, Activities, Items, Shopping, Proposals, Offers, Checklists, Roles
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', fontWeight: '500' }}>
|
||||
No data found in this holon
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#a16207' }}>
|
||||
This H3 cell may not have any data stored yet. You can add data using the + button above.
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div style={{ marginTop: '8px', color: '#ca8a04' }}>
|
||||
Loading data from GunDB...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -289,22 +289,25 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}
|
||||
|
||||
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<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
|
|
@ -333,7 +336,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
|
||||
console.log("✅ ImageGen: Mock image generated:", mockImageUrl)
|
||||
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
|
|
@ -413,7 +416,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
|
||||
if (imageUrl) {
|
||||
console.log('✅ ImageGen: Image generated successfully')
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
|
|
@ -461,7 +464,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}
|
||||
}
|
||||
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
|
|
@ -475,7 +478,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
const handleGenerate = () => {
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
generateImage(shape.props.prompt)
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { prompt: "" },
|
||||
|
|
@ -522,7 +525,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
<span style={{ flex: 1, lineHeight: "1.5" }}>{shape.props.error}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { error: null },
|
||||
|
|
@ -567,7 +570,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}}
|
||||
onError={(_e) => {
|
||||
console.error("❌ ImageGen: Failed to load image:", shape.props.imageUrl)
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
|
|
@ -650,7 +653,7 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
placeholder="Enter image prompt..."
|
||||
value={shape.props.prompt}
|
||||
onChange={(e) => {
|
||||
this.editor.updateShape<IImageGen>({
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { prompt: e.target.value },
|
||||
|
|
|
|||
|
|
@ -118,9 +118,10 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
}
|
||||
|
||||
getGeometry(shape: IMultmuxShape): 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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ export class PromptShape extends BaseBoxShapeUtil<IPrompt> {
|
|||
|
||||
// Override getGeometry to ensure the selector box always matches the rendered component height
|
||||
getGeometry(shape: IPrompt): Geometry2d {
|
||||
// isFilled must be true for proper hit testing and nearestPoint calculation
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: Math.max(shape.props.h, this.FIXED_HEIGHT),
|
||||
isFilled: false,
|
||||
width: Math.max(shape.props.w, 1),
|
||||
height: Math.max(shape.props.h, this.FIXED_HEIGHT, 1),
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -303,19 +303,19 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
// Handle pointer down events if needed
|
||||
}
|
||||
|
||||
override onBeforeCreate = (shape: ISharedPianoShape) => {
|
||||
override onBeforeCreate = (shape: ISharedPianoShape): ISharedPianoShape | void => {
|
||||
// Set default dimensions if not provided
|
||||
// Return the modified shape instead of calling updateShape (which causes infinite loops)
|
||||
if (!shape.props.w || !shape.props.h) {
|
||||
const { w, h } = getDefaultDimensions()
|
||||
this.editor.updateShape<ISharedPianoShape>({
|
||||
id: shape.id,
|
||||
type: "SharedPiano",
|
||||
return {
|
||||
...shape,
|
||||
props: {
|
||||
...shape.props,
|
||||
w,
|
||||
h,
|
||||
w: shape.props.w || w,
|
||||
h: shape.props.h || h,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,10 +41,11 @@ export class SlideShape extends BaseBoxShapeUtil<ISlideShape> {
|
|||
}
|
||||
|
||||
getGeometry(shape: ISlideShape): Geometry2d {
|
||||
// isFilled must be true for proper hit testing and nearestPoint calculation
|
||||
return new Rectangle2d({
|
||||
width: shape.props.w,
|
||||
height: shape.props.h,
|
||||
isFilled: false,
|
||||
width: Math.max(shape.props.w, 1),
|
||||
height: Math.max(shape.props.h, 1),
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,9 +57,10 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
}
|
||||
|
||||
getGeometry(shape: IVideoGen): 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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,391 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useEditor } from "tldraw"
|
||||
|
||||
// Command Palette that shows when holding Ctrl+Shift
|
||||
// Displays available keyboard shortcuts for custom tools and actions
|
||||
|
||||
interface ShortcutItem {
|
||||
id: string
|
||||
label: string
|
||||
kbd: string
|
||||
key: string // The actual key to press (e.g., 'V', 'C', etc.)
|
||||
icon?: string
|
||||
category: 'tool' | 'action'
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const editor = useEditor()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const holdTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const keysHeldRef = useRef({ ctrl: false, shift: false })
|
||||
|
||||
// Custom tools with Ctrl+Shift shortcuts (matching overrides.tsx)
|
||||
const customToolShortcuts: ShortcutItem[] = [
|
||||
{ id: 'VideoChat', label: 'Video Chat', kbd: '⌃⇧V', key: 'V', icon: '📹', category: 'tool' },
|
||||
{ id: 'ChatBox', label: 'Chat Box', kbd: '⌃⇧C', key: 'C', icon: '💬', category: 'tool' },
|
||||
{ id: 'Embed', label: 'Embed', kbd: '⌃⇧E', key: 'E', icon: '🔗', category: 'tool' },
|
||||
{ id: 'Slide', label: 'Slide', kbd: '⌃⇧S', key: 'S', icon: '📊', category: 'tool' },
|
||||
{ id: 'Markdown', label: 'Markdown', kbd: '⌃⇧M', key: 'M', icon: '📝', category: 'tool' },
|
||||
{ id: 'MycrozineTemplate', label: 'Mycrozine', kbd: '⌃⇧Z', key: 'Z', icon: '📰', category: 'tool' },
|
||||
{ id: 'Prompt', label: 'LLM Prompt', kbd: '⌃⇧L', key: 'L', icon: '🤖', category: 'tool' },
|
||||
{ id: 'ObsidianNote', label: 'Obsidian Note', kbd: '⌃⇧O', key: 'O', icon: '📓', category: 'tool' },
|
||||
{ id: 'Transcription', label: 'Transcription', kbd: '⌃⇧T', key: 'T', icon: '🎤', category: 'tool' },
|
||||
{ id: 'Holon', label: 'Holon', kbd: '⌃⇧H', key: 'H', icon: '⭕', category: 'tool' },
|
||||
{ id: 'FathomMeetings', label: 'Fathom Meetings', kbd: '⌃⇧F', key: 'F', icon: '📅', category: 'tool' },
|
||||
{ id: 'ImageGen', label: 'Image Gen', kbd: '⌃⇧I', key: 'I', icon: '🖼️', category: 'tool' },
|
||||
{ id: 'VideoGen', label: 'Video Gen', kbd: '⌃⇧G', key: 'G', icon: '🎬', category: 'tool' },
|
||||
{ id: 'Multmux', label: 'Terminal', kbd: '⌃⇧K', key: 'K', icon: '💻', category: 'tool' },
|
||||
]
|
||||
|
||||
// Custom actions with shortcuts (matching overrides.tsx)
|
||||
const customActionShortcuts: ShortcutItem[] = [
|
||||
{ id: 'zoom-to-selection', label: 'Zoom to Selection', kbd: 'Z', key: 'Z', icon: '🔍', category: 'action' },
|
||||
{ id: 'copy-link', label: 'Copy Link', kbd: '⌃⌥C', key: 'C', icon: '🔗', category: 'action' },
|
||||
{ id: 'lock-element', label: 'Lock Element', kbd: '⇧L', key: 'L', icon: '🔒', category: 'action' },
|
||||
{ id: 'search-shapes', label: 'Search Shapes', kbd: 'S', key: 'S', icon: '🔎', category: 'action' },
|
||||
{ id: 'semantic-search', label: 'Semantic Search', kbd: '⇧S', key: 'S', icon: '🧠', category: 'action' },
|
||||
{ id: 'ask-ai', label: 'Ask AI About Canvas', kbd: '⇧A', key: 'A', icon: '✨', category: 'action' },
|
||||
{ id: 'export-pdf', label: 'Export to PDF', kbd: '⌃⌥P', key: 'P', icon: '📄', category: 'action' },
|
||||
{ id: 'run-llm', label: 'Run LLM on Arrow', kbd: '⌃⌥R', key: 'R', icon: '⚡', category: 'action' },
|
||||
]
|
||||
|
||||
// Handle clicking on a tool/action
|
||||
const handleItemClick = useCallback((item: ShortcutItem) => {
|
||||
setIsVisible(false)
|
||||
|
||||
if (item.category === 'tool') {
|
||||
// Set the current tool
|
||||
editor.setCurrentTool(item.id)
|
||||
} else {
|
||||
// Dispatch keyboard event to trigger the action
|
||||
// Simulate the keyboard shortcut
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: item.key,
|
||||
code: `Key${item.key}`,
|
||||
ctrlKey: item.kbd.includes('⌃'),
|
||||
shiftKey: item.kbd.includes('⇧'),
|
||||
altKey: item.kbd.includes('⌥'),
|
||||
bubbles: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Handle Ctrl+Shift key press/release
|
||||
useEffect(() => {
|
||||
const checkAndShowPalette = () => {
|
||||
if (keysHeldRef.current.ctrl && keysHeldRef.current.shift) {
|
||||
// Clear any existing timeout
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current)
|
||||
}
|
||||
// Set a small delay before showing (to avoid flashing on quick combos)
|
||||
holdTimeoutRef.current = setTimeout(() => {
|
||||
setIsVisible(true)
|
||||
}, 300) // 300ms hold to show
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control') {
|
||||
keysHeldRef.current.ctrl = true
|
||||
checkAndShowPalette()
|
||||
} else if (e.key === 'Shift') {
|
||||
keysHeldRef.current.shift = true
|
||||
checkAndShowPalette()
|
||||
} else if (isVisible) {
|
||||
// Hide on any other key press (they're using a shortcut)
|
||||
setIsVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control') {
|
||||
keysHeldRef.current.ctrl = false
|
||||
} else if (e.key === 'Shift') {
|
||||
keysHeldRef.current.shift = false
|
||||
}
|
||||
|
||||
// Hide palette if either key is released
|
||||
if (!keysHeldRef.current.ctrl || !keysHeldRef.current.shift) {
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current)
|
||||
holdTimeoutRef.current = null
|
||||
}
|
||||
setIsVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="command-palette-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'none',
|
||||
animation: 'fadeIn 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="command-palette"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: '16px',
|
||||
padding: '24px 32px',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
maxWidth: '700px',
|
||||
width: '90%',
|
||||
animation: 'scaleIn 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
color: '#1a1a1a',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
⌨️ Command Palette
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '8px 0 0 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
Click a button or use Ctrl+Shift + Key to activate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tools Section */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3 style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#10b981',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '12px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
Tools (Ctrl+Shift + Key)
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{customToolShortcuts.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.08)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textAlign: 'left',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(16, 185, 129, 0.2)'
|
||||
e.currentTarget.style.borderColor = 'rgba(16, 185, 129, 0.4)'
|
||||
e.currentTarget.style.transform = 'scale(1.02)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(16, 185, 129, 0.08)'
|
||||
e.currentTarget.style.borderColor = 'rgba(16, 185, 129, 0.2)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
>
|
||||
<kbd style={{
|
||||
backgroundColor: '#10b981',
|
||||
color: '#fff',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'SF Mono, Monaco, monospace',
|
||||
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.3)',
|
||||
minWidth: '32px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{item.key}
|
||||
</kbd>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#1a1a1a',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div>
|
||||
<h3 style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#6366f1',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
marginBottom: '12px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
Actions
|
||||
</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{customActionShortcuts.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 12px',
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.08)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(99, 102, 241, 0.2)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
textAlign: 'left',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.2)'
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.4)'
|
||||
e.currentTarget.style.transform = 'scale(1.02)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)'
|
||||
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)'
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
}}
|
||||
>
|
||||
<kbd style={{
|
||||
backgroundColor: '#6366f1',
|
||||
color: '#fff',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'SF Mono, Monaco, monospace',
|
||||
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)',
|
||||
minWidth: '32px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{item.key}
|
||||
</kbd>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: '#1a1a1a',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '9px',
|
||||
color: '#888',
|
||||
fontFamily: 'SF Mono, Monaco, monospace',
|
||||
}}>
|
||||
{item.kbd}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
Press <kbd style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '3px',
|
||||
padding: '1px 4px',
|
||||
fontSize: '10px',
|
||||
}}>?</kbd> for full keyboard shortcuts dialog
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS Animations */}
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -242,7 +242,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...tools.ImageGen} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.VideoGen} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Multmux} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.MycelialIntelligence} disabled={hasSelection} />
|
||||
{/* MycelialIntelligence moved to permanent UI bar */}
|
||||
</TldrawUiMenuGroup>
|
||||
|
||||
{/* Collections Group */}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
const totalUsers = collaborators.length + 1
|
||||
|
||||
return (
|
||||
<div className="custom-people-menu">
|
||||
<div className="custom-people-menu" style={{ position: 'relative' }}>
|
||||
{/* Clickable avatar stack */}
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Click to see participants"
|
||||
>
|
||||
{/* Current user avatar */}
|
||||
<div
|
||||
title={`${myUserName} (you)`}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: myUserColor,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{myUserName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Other users (stacked) */}
|
||||
{collaborators.slice(0, 3).map((presence, index) => (
|
||||
<div
|
||||
key={presence.id}
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: presence.color,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
marginLeft: '-10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{(presence.userName || 'A').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* User count badge if more than shown */}
|
||||
{totalUsers > 1 && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text-1)',
|
||||
marginLeft: '6px',
|
||||
}}>
|
||||
{totalUsers}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown with user names */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
className="people-dropdown"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
minWidth: '180px',
|
||||
background: 'var(--bg-color, #fff)',
|
||||
border: '1px solid var(--border-color, #e1e4e8)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: 100000,
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--tool-text)',
|
||||
opacity: 0.7,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
Participants ({totalUsers})
|
||||
</div>
|
||||
|
||||
{/* Current user */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: myUserColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{myUserName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-color)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
{myUserName} (you)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Other users */}
|
||||
{collaborators.map((presence) => (
|
||||
<div
|
||||
key={presence.id}
|
||||
title={`${presence.userName || 'Anonymous'}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: presence.color,
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
marginLeft: '-8px',
|
||||
cursor: 'default',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* User count badge */}
|
||||
{collaborators.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-1)',
|
||||
marginLeft: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{collaborators.length + 1}
|
||||
{(presence.userName || 'A').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: 'var(--text-color)',
|
||||
}}>
|
||||
{presence.userName || 'Anonymous'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Click outside to close */}
|
||||
{showDropdown && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99999,
|
||||
}}
|
||||
onClick={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -89,6 +232,7 @@ function CustomInFrontOfCanvas() {
|
|||
<>
|
||||
<MycelialIntelligenceBar />
|
||||
<FocusLockIndicator />
|
||||
<CommandPalette />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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,18 +183,30 @@ function getAvailableProviders(availableKeys: Record<string, string>, settings:
|
|||
});
|
||||
}
|
||||
|
||||
// PRIORITY 1: Check for RunPod configuration from environment variables
|
||||
// RunPod is used as fallback when Ollama is not available
|
||||
// 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: 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');
|
||||
console.log('🔑 Found RunPod configuration from environment variables (generic endpoint)');
|
||||
providers.push({
|
||||
provider: 'runpod',
|
||||
apiKey: runpodConfig.apiKey,
|
||||
endpointId: runpodConfig.endpointId,
|
||||
model: 'default' // RunPod doesn't use model selection in the same way
|
||||
model: 'default'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Then add user-configured keys (they will be tried after RunPod)
|
||||
// First, try the preferred provider - support multiple keys if stored as comma-separated
|
||||
|
|
@ -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;
|
||||
if (responseText) {
|
||||
// Stream the response character by character for UX
|
||||
for (let i = 0; i < responseText.length; i++) {
|
||||
partial += responseText[i];
|
||||
onToken(partial, false);
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip malformed JSON chunks
|
||||
}
|
||||
// Small delay to simulate streaming
|
||||
if (i % 10 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue