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:
Jeff Emmett 2025-11-29 21:36:02 -08:00
parent 144f5365c1
commit 3738b9c56b
26 changed files with 1480 additions and 342 deletions

View File

@ -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'

8
multmux/.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
packages/*/node_modules
packages/*/dist
*.log
.git
.gitignore
README.md
infrastructure/

32
multmux/Dockerfile Normal file
View File

@ -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"]

View File

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

26
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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,

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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 }}

View File

@ -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 {

View File

@ -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)

View File

@ -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 />

View File

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

View File

@ -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 },

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
},
})
}
}
}

View File

@ -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,
})
}

View File

@ -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,
})
}

391
src/ui/CommandPalette.tsx Normal file
View File

@ -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>
)
}

View File

@ -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 */}

View File

@ -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) => {

View File

@ -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 />
</>
)
}

View File

@ -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: () => {

View File

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