From 527462acb782b00c4180a6935f20022f8adcc207 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 27 Nov 2025 22:18:25 -0800 Subject: [PATCH] feat: add IO Chip tool for visual I/O routing between canvas tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IOChipShapeUtil with frame-like container and pin system - Add IOChipTool for drawing IO chips on canvas - Add IOChipTemplateService for saving/loading chip templates - Add ChipTemplateBrowser component for template management - Add wiring system with type-compatible pin connections - Add IO chip CSS styles - Register IOChip in Board.tsx, overrides.tsx, and useAutomergeStoreV2.ts - Keyboard shortcuts: Alt+Shift+P (tool), Alt+T (template browser) Features: - Auto-analyze contained shapes to generate input/output pins - Visual wiring between pins with SVG curved connections - Save chips as reusable templates with categories - Built-in templates for common AI pipelines - Pin type compatibility checking for connections šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/automerge/useAutomergeStoreV2.ts | 3 + src/components/ChipTemplateBrowser.tsx | 455 ++++++++++ src/css/io-chip.css | 153 ++++ src/lib/IOChipTemplateService.ts | 364 ++++++++ src/routes/Board.tsx | 5 + src/shapes/IOChipShapeUtil.tsx | 1059 ++++++++++++++++++++++++ src/tools/IOChipTool.ts | 13 + src/ui/CustomToolbar.tsx | 34 +- src/ui/overrides.tsx | 20 + 9 files changed, 2105 insertions(+), 1 deletion(-) create mode 100644 src/components/ChipTemplateBrowser.tsx create mode 100644 src/css/io-chip.css create mode 100644 src/lib/IOChipTemplateService.ts create mode 100644 src/shapes/IOChipShapeUtil.tsx create mode 100644 src/tools/IOChipTool.ts diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index 6f3b6c3..b7a2661 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -127,6 +127,7 @@ import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeU import { ImageGenShape } from "@/shapes/ImageGenShapeUtil" import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" +import { IOChipShape } from "@/shapes/IOChipShapeUtil" // Location shape removed - no longer needed export function useAutomergeStoreV2({ @@ -160,6 +161,7 @@ export function useAutomergeStoreV2({ ImageGen: {} as any, VideoGen: {} as any, Multmux: {} as any, + IOChip: {} as any, }, bindings: defaultBindingSchemas, }) @@ -184,6 +186,7 @@ export function useAutomergeStoreV2({ ImageGenShape, VideoGenShape, MultmuxShape, + IOChipShape, ], }) return store diff --git a/src/components/ChipTemplateBrowser.tsx b/src/components/ChipTemplateBrowser.tsx new file mode 100644 index 0000000..0f601cc --- /dev/null +++ b/src/components/ChipTemplateBrowser.tsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { Editor, createShapeId } from 'tldraw' +import { ioChipTemplateService, IOChipTemplate, IOChipCategory } from '@/lib/IOChipTemplateService' +import { PIN_TYPE_ICONS, PIN_TYPE_COLORS, IOPinType } from '@/shapes/IOChipShapeUtil' + +interface ChipTemplateBrowserProps { + editor: Editor + onClose: () => void + position?: { x: number; y: number } +} + +export function ChipTemplateBrowser({ editor, onClose, position }: ChipTemplateBrowserProps) { + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [selectedCategory, setSelectedCategory] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [selectedTemplate, setSelectedTemplate] = useState(null) + + // Load templates and subscribe to changes + useEffect(() => { + const loadData = () => { + setTemplates(ioChipTemplateService.getAllTemplates()) + setCategories(ioChipTemplateService.getCategories()) + } + + loadData() + const unsubscribe = ioChipTemplateService.subscribe(loadData) + return unsubscribe + }, []) + + // Filter templates based on search and category + const filteredTemplates = useMemo(() => { + let result = templates + + if (selectedCategory) { + result = result.filter(t => t.category === selectedCategory) + } + + if (searchQuery.trim()) { + result = ioChipTemplateService.searchTemplates(searchQuery) + if (selectedCategory) { + result = result.filter(t => t.category === selectedCategory) + } + } + + return result + }, [templates, selectedCategory, searchQuery]) + + // Create chip from template + const handleCreateFromTemplate = (template: IOChipTemplate) => { + const viewport = editor.getViewportPageBounds() + const x = position?.x ?? (viewport.x + viewport.w / 2 - template.width / 2) + const y = position?.y ?? (viewport.y + viewport.h / 2 - template.height / 2) + + // Create the IO chip shape from template + editor.createShape({ + id: createShapeId(), + type: 'IOChip', + x, + y, + props: { + w: template.width, + h: template.height, + name: template.name, + description: template.description, + inputPins: template.inputPins, + outputPins: template.outputPins, + wires: template.wires, + containedShapeIds: [], + isAnalyzing: false, + lastAnalyzed: Date.now(), + pinnedToView: false, + tags: template.tags, + autoAnalyze: true, + showPinLabels: true, + templateId: template.id, + category: template.category, + }, + }) + + // TODO: Recreate contained shapes from template + // This would require storing and restoring shape definitions + + onClose() + } + + // Delete template + const handleDeleteTemplate = (templateId: string, e: React.MouseEvent) => { + e.stopPropagation() + if (confirm('Are you sure you want to delete this template?')) { + ioChipTemplateService.deleteTemplate(templateId) + if (selectedTemplate?.id === templateId) { + setSelectedTemplate(null) + } + } + } + + // Export template + const handleExportTemplate = (template: IOChipTemplate, e: React.MouseEvent) => { + e.stopPropagation() + const json = ioChipTemplateService.exportTemplate(template.id) + if (json) { + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${template.name.toLowerCase().replace(/\s+/g, '-')}.iochip.json` + a.click() + URL.revokeObjectURL(url) + } + } + + // Render pin preview + const renderPinPreview = (pins: { type: IOPinType }[], direction: 'input' | 'output') => { + return ( +
+ {pins.slice(0, 5).map((pin, i) => ( + + {PIN_TYPE_ICONS[pin.type]} + + ))} + {pins.length > 5 && ( + +{pins.length - 5} + )} +
+ ) + } + + return ( +
e.stopPropagation()} + > + {/* Header */} +
+
+

+ šŸ”Œ IO Chip Templates +

+

+ Select a template to create a new IO chip +

+
+ +
+ + {/* Search and filters */} +
+ setSearchQuery(e.target.value)} + placeholder="Search templates..." + style={{ + width: '100%', + padding: '10px 14px', + border: '1px solid #e2e8f0', + borderRadius: '8px', + fontSize: '14px', + marginBottom: '12px', + }} + /> + + {/* Category filters */} +
+ + {categories.map((cat) => ( + + ))} +
+
+ + {/* Template grid */} +
+ {filteredTemplates.length === 0 ? ( +
+
šŸ“¦
+
+ {searchQuery ? 'No templates match your search' : 'No templates yet'} +
+
+ Create an IO chip and save it as a template +
+
+ ) : ( +
+ {filteredTemplates.map((template) => ( +
setSelectedTemplate(template)} + style={{ + padding: '14px', + borderRadius: '10px', + border: selectedTemplate?.id === template.id + ? '2px solid #3b82f6' + : '1px solid #e2e8f0', + backgroundColor: selectedTemplate?.id === template.id + ? '#eff6ff' + : 'white', + cursor: 'pointer', + transition: 'all 0.15s ease', + }} + onMouseEnter={(e) => { + if (selectedTemplate?.id !== template.id) { + e.currentTarget.style.borderColor = '#94a3b8' + } + }} + onMouseLeave={(e) => { + if (selectedTemplate?.id !== template.id) { + e.currentTarget.style.borderColor = '#e2e8f0' + } + }} + > +
+
+ {template.icon || 'šŸ”Œ'} {template.name} +
+
+ + +
+
+ + {template.description && ( +
+ {template.description.slice(0, 80)} + {template.description.length > 80 ? '...' : ''} +
+ )} + +
+
+
+ Inputs +
+ {renderPinPreview(template.inputPins, 'input')} +
+
+
+ Outputs +
+ {renderPinPreview(template.outputPins, 'output')} +
+
+ +
+ {template.tags.slice(0, 3).map((tag, i) => ( + + {tag} + + ))} +
+
+ ))} +
+ )} +
+ + {/* Footer with actions */} + {selectedTemplate && ( +
+
+ Selected: {selectedTemplate.name} + + ({selectedTemplate.inputPins.length} inputs, {selectedTemplate.outputPins.length} outputs) + +
+ +
+ )} +
+ ) +} + +// Hook to manage template browser visibility +export function useChipTemplateBrowser() { + const [isOpen, setIsOpen] = useState(false) + const [position, setPosition] = useState<{ x: number; y: number } | undefined>() + + const open = (pos?: { x: number; y: number }) => { + setPosition(pos) + setIsOpen(true) + } + + const close = () => { + setIsOpen(false) + setPosition(undefined) + } + + return { isOpen, position, open, close } +} diff --git a/src/css/io-chip.css b/src/css/io-chip.css new file mode 100644 index 0000000..2354525 --- /dev/null +++ b/src/css/io-chip.css @@ -0,0 +1,153 @@ +/* IO Chip Styles */ + +/* Wire animation */ +@keyframes wire-flow { + from { + stroke-dashoffset: 0; + } + to { + stroke-dashoffset: -20; + } +} + +.io-chip-wire-animated path { + animation: wire-flow 1s linear infinite; +} + +/* Pin hover effects */ +.io-chip-pin { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.io-chip-pin:hover { + transform: scale(1.1); +} + +/* Wiring mode cursor */ +.io-chip-wiring-mode { + cursor: crosshair !important; +} + +.io-chip-wiring-mode * { + cursor: crosshair !important; +} + +/* Template browser scrollbar */ +.io-chip-template-browser::-webkit-scrollbar { + width: 8px; +} + +.io-chip-template-browser::-webkit-scrollbar-track { + background: #f1f5f9; + border-radius: 4px; +} + +.io-chip-template-browser::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +.io-chip-template-browser::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* Pin type badges */ +.io-pin-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; +} + +.io-pin-badge-text { background: #dbeafe; color: #1d4ed8; } +.io-pin-badge-image { background: #ede9fe; color: #7c3aed; } +.io-pin-badge-video { background: #fce7f3; color: #db2777; } +.io-pin-badge-url { background: #cffafe; color: #0891b2; } +.io-pin-badge-file { background: #fef3c7; color: #d97706; } +.io-pin-badge-identity { background: #d1fae5; color: #059669; } +.io-pin-badge-api { background: #fee2e2; color: #dc2626; } +.io-pin-badge-shape { background: #e0e7ff; color: #4f46e5; } +.io-pin-badge-data { background: #ecfccb; color: #65a30d; } +.io-pin-badge-prompt { background: #ffedd5; color: #ea580c; } +.io-pin-badge-embedding { background: #ccfbf1; color: #0d9488; } +.io-pin-badge-stream { background: #e0f2fe; color: #0284c7; } + +/* Template card hover effect */ +.io-chip-template-card { + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.io-chip-template-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/* Save dialog animation */ +@keyframes dialog-appear { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.io-chip-save-dialog { + animation: dialog-appear 0.2s ease; +} + +/* Wire connection indicator */ +.io-chip-wire-indicator { + position: absolute; + width: 8px; + height: 8px; + background: #10b981; + border-radius: 50%; + animation: pulse 1.5s ease infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(1.2); + } +} + +/* Chip container drop zone */ +.io-chip-drop-zone { + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.io-chip-drop-zone.active { + border-color: #3b82f6 !important; + background-color: #eff6ff !important; +} + +/* Dark mode support */ +.dark .io-chip-template-browser { + background: #1e293b; + border-color: #334155; +} + +.dark .io-chip-template-card { + background: #0f172a; + border-color: #334155; +} + +.dark .io-chip-template-card:hover { + border-color: #475569; +} + +.dark .io-pin-label { + background: rgba(30, 41, 59, 0.9); + color: #e2e8f0; +} diff --git a/src/lib/IOChipTemplateService.ts b/src/lib/IOChipTemplateService.ts new file mode 100644 index 0000000..38f23d2 --- /dev/null +++ b/src/lib/IOChipTemplateService.ts @@ -0,0 +1,364 @@ +import { TLShapeId } from "tldraw" +import { IOPin, IOPinType } from "@/shapes/IOChipShapeUtil" + +// Wire connection between two pins +export interface IOWireConnection { + id: string + fromPinId: string + toPinId: string + fromShapeId: TLShapeId + toShapeId: TLShapeId + pinType: IOPinType +} + +// Contained shape reference with relative position +export interface ContainedShapeRef { + originalId: TLShapeId + type: string + relativeX: number // Position relative to chip origin + relativeY: number + props: Record // Sanitized props (no sensitive data) +} + +// Full chip template schema +export interface IOChipTemplate { + id: string + name: string + description?: string + category?: string + icon?: string + createdAt: number + updatedAt: number + + // Chip dimensions + width: number + height: number + + // I/O schema + inputPins: IOPin[] + outputPins: IOPin[] + + // Internal structure + containedShapes: ContainedShapeRef[] + wires: IOWireConnection[] + + // Metadata + tags: string[] + author?: string + version?: string +} + +// Template category for organization +export interface IOChipCategory { + id: string + name: string + icon: string + description?: string +} + +// Default categories +export const DEFAULT_CATEGORIES: IOChipCategory[] = [ + { id: 'ai', name: 'AI & ML', icon: 'šŸ¤–', description: 'AI and machine learning pipelines' }, + { id: 'media', name: 'Media', icon: 'šŸŽ¬', description: 'Image, video, and audio processing' }, + { id: 'data', name: 'Data', icon: 'šŸ“Š', description: 'Data transformation and analysis' }, + { id: 'integration', name: 'Integration', icon: 'šŸ”—', description: 'API and service integrations' }, + { id: 'utility', name: 'Utility', icon: 'šŸ”§', description: 'General purpose utilities' }, + { id: 'custom', name: 'Custom', icon: '⭐', description: 'User-created templates' }, +] + +// Storage key +const TEMPLATES_STORAGE_KEY = 'io-chip-templates' +const CATEGORIES_STORAGE_KEY = 'io-chip-categories' + +class IOChipTemplateService { + private templates: Map = new Map() + private categories: IOChipCategory[] = [...DEFAULT_CATEGORIES] + private listeners: Set<() => void> = new Set() + + constructor() { + this.loadFromStorage() + } + + // Load templates from localStorage + private loadFromStorage(): void { + try { + const stored = localStorage.getItem(TEMPLATES_STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) as IOChipTemplate[] + this.templates = new Map(parsed.map(t => [t.id, t])) + } + + const storedCategories = localStorage.getItem(CATEGORIES_STORAGE_KEY) + if (storedCategories) { + this.categories = JSON.parse(storedCategories) + } + } catch (error) { + console.error('āŒ Failed to load IO chip templates:', error) + } + } + + // Save templates to localStorage + private saveToStorage(): void { + try { + const templates = Array.from(this.templates.values()) + localStorage.setItem(TEMPLATES_STORAGE_KEY, JSON.stringify(templates)) + localStorage.setItem(CATEGORIES_STORAGE_KEY, JSON.stringify(this.categories)) + this.notifyListeners() + } catch (error) { + console.error('āŒ Failed to save IO chip templates:', error) + } + } + + // Subscribe to changes + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private notifyListeners(): void { + this.listeners.forEach(listener => listener()) + } + + // Generate unique ID + private generateId(): string { + return `chip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + } + + // Save a new template + saveTemplate(template: Omit): IOChipTemplate { + const now = Date.now() + const newTemplate: IOChipTemplate = { + ...template, + id: this.generateId(), + createdAt: now, + updatedAt: now, + } + + this.templates.set(newTemplate.id, newTemplate) + this.saveToStorage() + + console.log('šŸ’¾ Saved IO chip template:', newTemplate.name) + return newTemplate + } + + // Update existing template + updateTemplate(id: string, updates: Partial): IOChipTemplate | null { + const existing = this.templates.get(id) + if (!existing) { + console.error('āŒ Template not found:', id) + return null + } + + const updated: IOChipTemplate = { + ...existing, + ...updates, + id, // Preserve ID + createdAt: existing.createdAt, // Preserve creation time + updatedAt: Date.now(), + } + + this.templates.set(id, updated) + this.saveToStorage() + + console.log('šŸ“ Updated IO chip template:', updated.name) + return updated + } + + // Delete template + deleteTemplate(id: string): boolean { + const deleted = this.templates.delete(id) + if (deleted) { + this.saveToStorage() + console.log('šŸ—‘ļø Deleted IO chip template:', id) + } + return deleted + } + + // Get single template + getTemplate(id: string): IOChipTemplate | undefined { + return this.templates.get(id) + } + + // Get all templates + getAllTemplates(): IOChipTemplate[] { + return Array.from(this.templates.values()).sort((a, b) => b.updatedAt - a.updatedAt) + } + + // Get templates by category + getTemplatesByCategory(categoryId: string): IOChipTemplate[] { + return this.getAllTemplates().filter(t => t.category === categoryId) + } + + // Search templates + searchTemplates(query: string): IOChipTemplate[] { + const lowerQuery = query.toLowerCase() + return this.getAllTemplates().filter(t => + t.name.toLowerCase().includes(lowerQuery) || + t.description?.toLowerCase().includes(lowerQuery) || + t.tags.some(tag => tag.toLowerCase().includes(lowerQuery)) + ) + } + + // Get all categories + getCategories(): IOChipCategory[] { + return this.categories + } + + // Add custom category + addCategory(category: Omit): IOChipCategory { + const newCategory: IOChipCategory = { + ...category, + id: `cat-${Date.now()}`, + } + this.categories.push(newCategory) + this.saveToStorage() + return newCategory + } + + // Export template as JSON + exportTemplate(id: string): string | null { + const template = this.templates.get(id) + if (!template) return null + return JSON.stringify(template, null, 2) + } + + // Import template from JSON + importTemplate(json: string): IOChipTemplate | null { + try { + const template = JSON.parse(json) as IOChipTemplate + // Generate new ID to avoid conflicts + template.id = this.generateId() + template.createdAt = Date.now() + template.updatedAt = Date.now() + + this.templates.set(template.id, template) + this.saveToStorage() + + console.log('šŸ“„ Imported IO chip template:', template.name) + return template + } catch (error) { + console.error('āŒ Failed to import template:', error) + return null + } + } + + // Export all templates + exportAllTemplates(): string { + return JSON.stringify(this.getAllTemplates(), null, 2) + } + + // Import multiple templates + importTemplates(json: string): number { + try { + const templates = JSON.parse(json) as IOChipTemplate[] + let count = 0 + + for (const template of templates) { + template.id = this.generateId() + template.createdAt = Date.now() + template.updatedAt = Date.now() + this.templates.set(template.id, template) + count++ + } + + this.saveToStorage() + console.log(`šŸ“„ Imported ${count} IO chip templates`) + return count + } catch (error) { + console.error('āŒ Failed to import templates:', error) + return 0 + } + } + + // Create some built-in example templates + createBuiltInTemplates(): void { + if (this.templates.size > 0) return // Don't overwrite existing + + // Image Generation Pipeline + this.saveTemplate({ + name: 'Text to Image', + description: 'Generate images from text prompts using AI', + category: 'ai', + icon: 'šŸŽØ', + width: 500, + height: 300, + inputPins: [ + { id: 'prompt-in', name: 'Prompt', type: 'prompt', direction: 'input', required: true }, + { id: 'style-in', name: 'Style', type: 'text', direction: 'input', required: false }, + ], + outputPins: [ + { id: 'image-out', name: 'Generated Image', type: 'image', direction: 'output' }, + ], + containedShapes: [], + wires: [], + tags: ['ai', 'image', 'generation', 'stable-diffusion'], + }) + + // Video Generation Pipeline + this.saveTemplate({ + name: 'Image to Video', + description: 'Animate images into videos using AI', + category: 'ai', + icon: 'šŸŽ¬', + width: 600, + height: 350, + inputPins: [ + { id: 'image-in', name: 'Source Image', type: 'image', direction: 'input', required: true }, + { id: 'prompt-in', name: 'Motion Prompt', type: 'prompt', direction: 'input', required: false }, + ], + outputPins: [ + { id: 'video-out', name: 'Generated Video', type: 'video', direction: 'output' }, + ], + containedShapes: [], + wires: [], + tags: ['ai', 'video', 'animation', 'wan'], + }) + + // Chat Pipeline + this.saveTemplate({ + name: 'AI Chat', + description: 'Conversational AI with context', + category: 'ai', + icon: 'šŸ’¬', + width: 450, + height: 400, + inputPins: [ + { id: 'message-in', name: 'User Message', type: 'text', direction: 'input', required: true }, + { id: 'context-in', name: 'Context', type: 'data', direction: 'input', required: false }, + ], + outputPins: [ + { id: 'response-out', name: 'AI Response', type: 'text', direction: 'output' }, + ], + containedShapes: [], + wires: [], + tags: ['ai', 'chat', 'llm', 'conversation'], + }) + + // Transcription Pipeline + this.saveTemplate({ + name: 'Audio Transcription', + description: 'Convert speech to text', + category: 'media', + icon: 'šŸŽ¤', + width: 400, + height: 250, + inputPins: [ + { id: 'audio-in', name: 'Audio File', type: 'file', direction: 'input', required: true }, + ], + outputPins: [ + { id: 'transcript-out', name: 'Transcript', type: 'text', direction: 'output' }, + ], + containedShapes: [], + wires: [], + tags: ['audio', 'transcription', 'speech-to-text', 'whisper'], + }) + + console.log('šŸ“¦ Created built-in IO chip templates') + } +} + +// Singleton instance +export const ioChipTemplateService = new IOChipTemplateService() + +// Initialize built-in templates +ioChipTemplateService.createBuiltInTemplates() diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index a0022d3..b8792b2 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -46,6 +46,8 @@ import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { VideoGenTool } from "@/tools/VideoGenTool" import { MultmuxTool } from "@/tools/MultmuxTool" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" +import { IOChipTool } from "@/tools/IOChipTool" +import { IOChipShape } from "@/shapes/IOChipShapeUtil" import { lockElement, unlockElement, @@ -62,6 +64,7 @@ import { CmdK } from "@/CmdK" import "react-cmdk/dist/cmdk.css" import "@/css/style.css" import "@/css/obsidian-browser.css" +import "@/css/io-chip.css" const collections: Collection[] = [GraphLayoutCollection] import { useAuth } from "../context/AuthContext" @@ -87,6 +90,7 @@ const customShapeUtils = [ ImageGenShape, VideoGenShape, MultmuxShape, + IOChipShape, ] const customTools = [ ChatBoxTool, @@ -104,6 +108,7 @@ const customTools = [ ImageGenTool, VideoGenTool, MultmuxTool, + IOChipTool, ] // Debug: Log tool and shape registration info diff --git a/src/shapes/IOChipShapeUtil.tsx b/src/shapes/IOChipShapeUtil.tsx new file mode 100644 index 0000000..b68a52e --- /dev/null +++ b/src/shapes/IOChipShapeUtil.tsx @@ -0,0 +1,1059 @@ +import { + BaseBoxShapeUtil, + HTMLContainer, + TLBaseShape, + TLShapeId, + Box, + TLShape, +} from "tldraw" +import React, { useState, useEffect, useCallback, useMemo } from "react" +import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" +import { usePinnedToView } from "../hooks/usePinnedToView" +import { ioChipTemplateService, IOWireConnection, ContainedShapeRef, IOChipTemplate } from "@/lib/IOChipTemplateService" + +// Pin types that can be detected or defined +export type IOPinType = + | 'text' // Text input/output + | 'image' // Image data + | 'video' // Video data + | 'url' // URL/link + | 'file' // File upload + | 'identity' // User identity/auth + | 'api' // API endpoint + | 'shape' // Shape reference + | 'data' // Generic data + | 'prompt' // AI prompt + | 'embedding' // Vector embedding + | 'stream' // Streaming data + +export interface IOPin { + id: string + name: string + type: IOPinType + direction: 'input' | 'output' + description?: string + required?: boolean + connected?: boolean + connectedTo?: string // Pin ID it's connected to + connectedToShape?: TLShapeId // Shape the connected pin belongs to + value?: any + sourceShapeId?: TLShapeId // The shape this pin was derived from +} + +type IIOChip = TLBaseShape< + "IOChip", + { + w: number + h: number + name: string + description?: string + inputPins: IOPin[] + outputPins: IOPin[] + wires: IOWireConnection[] // Internal wiring between contained shapes + containedShapeIds: TLShapeId[] + isAnalyzing: boolean + lastAnalyzed: number + pinnedToView: boolean + tags: string[] + autoAnalyze: boolean + showPinLabels: boolean + templateId?: string // Reference to saved template + category?: string + } +> + +// Shape type to I/O mapping - defines what inputs/outputs each shape type has +const SHAPE_IO_MAPPINGS: Record[]; outputs: Partial[] }> = { + 'ImageGen': { + inputs: [ + { name: 'Prompt', type: 'prompt', required: true }, + { name: 'Endpoint', type: 'api', required: false }, + ], + outputs: [ + { name: 'Image', type: 'image' }, + ], + }, + 'VideoGen': { + inputs: [ + { name: 'Prompt', type: 'prompt', required: true }, + { name: 'Source Image', type: 'image', required: false }, + ], + outputs: [ + { name: 'Video', type: 'video' }, + ], + }, + 'ChatBox': { + inputs: [ + { name: 'Message', type: 'text', required: true }, + { name: 'Context', type: 'text', required: false }, + ], + outputs: [ + { name: 'Response', type: 'text' }, + ], + }, + 'Prompt': { + inputs: [ + { name: 'Prompt Text', type: 'prompt', required: true }, + { name: 'Context', type: 'text', required: false }, + ], + outputs: [ + { name: 'LLM Response', type: 'text' }, + ], + }, + 'Transcription': { + inputs: [ + { name: 'Audio', type: 'file', required: true }, + ], + outputs: [ + { name: 'Transcript', type: 'text' }, + ], + }, + 'Embed': { + inputs: [ + { name: 'URL', type: 'url', required: true }, + ], + outputs: [ + { name: 'Embed', type: 'shape' }, + ], + }, + 'Markdown': { + inputs: [ + { name: 'Markdown', type: 'text', required: true }, + ], + outputs: [ + { name: 'Rendered', type: 'shape' }, + ], + }, + 'Holon': { + inputs: [ + { name: 'Holon ID', type: 'text', required: true }, + ], + outputs: [ + { name: 'Data', type: 'data' }, + ], + }, + 'Multmux': { + inputs: [ + { name: 'Command', type: 'text', required: true }, + ], + outputs: [ + { name: 'Output', type: 'text' }, + { name: 'Exit Code', type: 'data' }, + ], + }, + 'MycelialIntelligence': { + inputs: [ + { name: 'Query', type: 'prompt', required: true }, + { name: 'Context', type: 'data', required: false }, + ], + outputs: [ + { name: 'Response', type: 'text' }, + { name: 'Actions', type: 'data' }, + ], + }, + 'IOChip': { + inputs: [ + { name: 'Input', type: 'data', required: false }, + ], + outputs: [ + { name: 'Output', type: 'data' }, + ], + }, + // Default for unknown shapes + 'default': { + inputs: [ + { name: 'Input', type: 'data' }, + ], + outputs: [ + { name: 'Output', type: 'data' }, + ], + }, +} + +// Pin type icons - exported for use in other components +export const PIN_TYPE_ICONS: Record = { + 'text': 'šŸ“', + 'image': 'šŸ–¼ļø', + 'video': 'šŸŽ¬', + 'url': 'šŸ”—', + 'file': 'šŸ“', + 'identity': 'šŸ‘¤', + 'api': 'šŸ”Œ', + 'shape': '⬔', + 'data': 'šŸ“Š', + 'prompt': 'šŸ’­', + 'embedding': '🧬', + 'stream': '🌊', +} + +// Pin type colors - exported for use in other components +export const PIN_TYPE_COLORS: Record = { + 'text': '#3b82f6', // blue + 'image': '#8b5cf6', // purple + 'video': '#ec4899', // pink + 'url': '#06b6d4', // cyan + 'file': '#f59e0b', // amber + 'identity': '#10b981', // emerald + 'api': '#ef4444', // red + 'shape': '#6366f1', // indigo + 'data': '#84cc16', // lime + 'prompt': '#f97316', // orange + 'embedding': '#14b8a6', // teal + 'stream': '#0ea5e9', // sky +} + +// Check if two pin types are compatible for connection +export function arePinTypesCompatible(type1: IOPinType, type2: IOPinType): boolean { + if (type1 === type2) return true + + // Define compatible type pairs + const compatibilityMap: Record = { + 'text': ['prompt', 'data'], + 'prompt': ['text', 'data'], + 'data': ['text', 'prompt', 'url', 'file', 'image', 'video', 'shape', 'embedding', 'stream'], + 'url': ['text', 'data'], + 'file': ['data'], + 'image': ['data', 'file'], + 'video': ['data', 'file'], + 'identity': ['data'], + 'api': ['data', 'url'], + 'shape': ['data'], + 'embedding': ['data'], + 'stream': ['data', 'text'], + } + + return compatibilityMap[type1]?.includes(type2) || compatibilityMap[type2]?.includes(type1) +} + +export class IOChipShape extends BaseBoxShapeUtil { + static override type = "IOChip" as const + + // IO Chip theme color: Electric blue + static readonly PRIMARY_COLOR = "#3b82f6" + + getDefaultProps(): IIOChip["props"] { + return { + w: 400, + h: 300, + name: "IO Chip", + description: "Drag shapes into this chip to automatically analyze their I/O", + inputPins: [], + outputPins: [], + wires: [], + containedShapeIds: [], + isAnalyzing: false, + lastAnalyzed: 0, + pinnedToView: false, + tags: ['io-chip'], + autoAnalyze: true, + showPinLabels: true, + } + } + + // Override canReceiveNewChildrenOfType to allow shapes to be parented to this shape + override canReceiveNewChildrenOfType(_shape: IIOChip, _type: string): boolean { + return true + } + + component(shape: IIOChip) { + const { + w, h, name, description, inputPins, outputPins, wires, + containedShapeIds, isAnalyzing, autoAnalyze, showPinLabels + } = shape.props + + const [isHovering, setIsHovering] = useState(false) + const [isMinimized, setIsMinimized] = useState(false) + const [selectedPin, setSelectedPin] = useState(null) + const [hoveredPin, setHoveredPin] = useState(null) + const [showSaveDialog, setShowSaveDialog] = useState(false) + const [saveName, setSaveName] = useState(name) + const [saveDescription, setSaveDescription] = useState(description || '') + const [saveCategory, setSaveCategory] = useState('custom') + const [wiringMode, setWiringMode] = useState(false) + const [wireStartPin, setWireStartPin] = useState(null) + + const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) + + // Use the pinning hook + usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) + + // Get shapes contained within this IO chip's bounds + const getContainedShapes = useCallback(() => { + const chipBounds = new Box(shape.x, shape.y, w, h) + const allShapes = this.editor.getCurrentPageShapes() + + return allShapes.filter(s => { + if (s.id === shape.id) return false + if (s.type === 'IOWire') return false // Don't include wire shapes + + const shapeBounds = this.editor.getShapePageBounds(s.id) + if (!shapeBounds) return false + + // Check if shape is fully contained within the chip + return chipBounds.contains(shapeBounds) + }) + }, [shape.id, shape.x, shape.y, w, h, this.editor]) + + // Analyze contained shapes and generate pins + const analyzeContainedShapes = useCallback(async () => { + const containedShapes = getContainedShapes() + const newInputPins: IOPin[] = [] + const newOutputPins: IOPin[] = [] + const newContainedIds: TLShapeId[] = [] + + for (const containedShape of containedShapes) { + newContainedIds.push(containedShape.id) + + // Get I/O mapping for this shape type + const mapping = SHAPE_IO_MAPPINGS[containedShape.type] || SHAPE_IO_MAPPINGS['default'] + + // Create input pins for this shape + mapping.inputs.forEach((inputDef, idx) => { + const existingPin = inputPins.find(p => p.id === `${containedShape.id}-input-${idx}`) + newInputPins.push({ + id: `${containedShape.id}-input-${idx}`, + name: `${containedShape.type}: ${inputDef.name}`, + type: inputDef.type || 'data', + direction: 'input', + description: inputDef.description || `Input for ${containedShape.type}`, + required: inputDef.required, + sourceShapeId: containedShape.id, + // Preserve existing connection state + connected: existingPin?.connected, + connectedTo: existingPin?.connectedTo, + connectedToShape: existingPin?.connectedToShape, + }) + }) + + // Create output pins for this shape + mapping.outputs.forEach((outputDef, idx) => { + const existingPin = outputPins.find(p => p.id === `${containedShape.id}-output-${idx}`) + newOutputPins.push({ + id: `${containedShape.id}-output-${idx}`, + name: `${containedShape.type}: ${outputDef.name}`, + type: outputDef.type || 'data', + direction: 'output', + description: outputDef.description || `Output from ${containedShape.type}`, + sourceShapeId: containedShape.id, + // Preserve existing connection state + connected: existingPin?.connected, + connectedTo: existingPin?.connectedTo, + connectedToShape: existingPin?.connectedToShape, + }) + }) + } + + // Update the shape with new pins + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + inputPins: newInputPins, + outputPins: newOutputPins, + containedShapeIds: newContainedIds, + lastAnalyzed: Date.now(), + isAnalyzing: false, + }, + }) + }, [getContainedShapes, shape.id, shape.props, inputPins, outputPins, this.editor]) + + // Auto-analyze when shapes change (if enabled) + useEffect(() => { + if (autoAnalyze) { + const currentShapes = getContainedShapes() + const currentIds = currentShapes.map(s => s.id) + const storedIds = containedShapeIds + + // Check if shapes have changed + const hasChanged = + currentIds.length !== storedIds.length || + currentIds.some(id => !storedIds.includes(id)) + + if (hasChanged) { + analyzeContainedShapes() + } + } + }, [autoAnalyze, getContainedShapes, containedShapeIds, analyzeContainedShapes]) + + const handleClose = () => { + this.editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handlePinToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + + const handlePinClick = (pin: IOPin) => { + if (wiringMode) { + // Handle wire creation + if (!wireStartPin) { + // Start wire from this pin + setWireStartPin(pin) + } else { + // Complete wire to this pin + if (wireStartPin.id !== pin.id && wireStartPin.direction !== pin.direction) { + // Check type compatibility + if (arePinTypesCompatible(wireStartPin.type, pin.type)) { + const fromPin = wireStartPin.direction === 'output' ? wireStartPin : pin + const toPin = wireStartPin.direction === 'input' ? wireStartPin : pin + + // Create wire connection + const newWire: IOWireConnection = { + id: `wire-${Date.now()}`, + fromPinId: fromPin.id, + toPinId: toPin.id, + fromShapeId: fromPin.sourceShapeId!, + toShapeId: toPin.sourceShapeId!, + pinType: fromPin.type, + } + + // Update pins with connection info + const updatedInputPins = inputPins.map(p => { + if (p.id === toPin.id) { + return { ...p, connected: true, connectedTo: fromPin.id, connectedToShape: fromPin.sourceShapeId } + } + return p + }) + + const updatedOutputPins = outputPins.map(p => { + if (p.id === fromPin.id) { + return { ...p, connected: true, connectedTo: toPin.id, connectedToShape: toPin.sourceShapeId } + } + return p + }) + + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + inputPins: updatedInputPins, + outputPins: updatedOutputPins, + wires: [...wires, newWire], + }, + }) + } + } + setWireStartPin(null) + } + } else { + setSelectedPin(pin) + // Emit event for pin interaction + const event = new CustomEvent('io-chip-pin-click', { + detail: { pin, chipId: shape.id } + }) + window.dispatchEvent(event) + } + } + + const handleAnalyze = () => { + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + isAnalyzing: true, + }, + }) + analyzeContainedShapes() + } + + // Save chip as template + const handleSaveAsTemplate = () => { + const containedShapes = getContainedShapes() + + // Create contained shape references with relative positions + const containedShapeRefs: ContainedShapeRef[] = containedShapes.map(s => ({ + originalId: s.id, + type: s.type, + relativeX: s.x - shape.x, + relativeY: s.y - shape.y, + props: { ...s.props }, + })) + + const template = ioChipTemplateService.saveTemplate({ + name: saveName, + description: saveDescription, + category: saveCategory, + width: w, + height: h, + inputPins: inputPins, + outputPins: outputPins, + containedShapes: containedShapeRefs, + wires: wires, + tags: shape.props.tags, + }) + + // Update shape with template reference + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + templateId: template.id, + name: saveName, + description: saveDescription, + category: saveCategory, + }, + }) + + setShowSaveDialog(false) + } + + // Delete a wire connection + const handleDeleteWire = (wireId: string) => { + const wire = wires.find(w => w.id === wireId) + if (!wire) return + + // Update pins to remove connection + const updatedInputPins = inputPins.map(p => { + if (p.id === wire.toPinId) { + return { ...p, connected: false, connectedTo: undefined, connectedToShape: undefined } + } + return p + }) + + const updatedOutputPins = outputPins.map(p => { + if (p.id === wire.fromPinId) { + return { ...p, connected: false, connectedTo: undefined, connectedToShape: undefined } + } + return p + }) + + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + inputPins: updatedInputPins, + outputPins: updatedOutputPins, + wires: wires.filter(w => w.id !== wireId), + }, + }) + } + + // Calculate pin positions + const pinHeight = 28 + const pinSpacing = 8 + const headerHeight = 40 + const contentPadding = 12 + + // Get pin position for wire rendering + const getPinPosition = (pin: IOPin, index: number, isInput: boolean) => { + const yOffset = headerHeight + contentPadding + index * (pinHeight + pinSpacing) + pinHeight / 2 + const xOffset = isInput ? 0 : w + return { x: xOffset, y: yOffset } + } + + // Render a single pin + const renderPin = (pin: IOPin, index: number, isInput: boolean) => { + const yOffset = headerHeight + contentPadding + index * (pinHeight + pinSpacing) + const isHovered = hoveredPin === pin.id + const isSelectedPin = selectedPin?.id === pin.id + const isWireStart = wireStartPin?.id === pin.id + const color = PIN_TYPE_COLORS[pin.type] + const icon = PIN_TYPE_ICONS[pin.type] + + return ( +
setHoveredPin(pin.id)} + onMouseLeave={() => setHoveredPin(null)} + onPointerDown={(e) => { + e.stopPropagation() + handlePinClick(pin) + }} + > + {/* Pin connector */} +
+ {icon} +
+ + {/* Pin label */} + {showPinLabels && ( +
+ {pin.connected && āœ“} + {pin.name} + {pin.required && *} +
+ )} +
+ ) + } + + // Render internal wire connections as SVG lines + const renderWires = () => { + return wires.map(wire => { + const fromPinIndex = outputPins.findIndex(p => p.id === wire.fromPinId) + const toPinIndex = inputPins.findIndex(p => p.id === wire.toPinId) + + if (fromPinIndex === -1 || toPinIndex === -1) return null + + const fromPos = getPinPosition(outputPins[fromPinIndex], fromPinIndex, false) + const toPos = getPinPosition(inputPins[toPinIndex], toPinIndex, true) + + const color = PIN_TYPE_COLORS[wire.pinType] + + // Calculate control points for curved wire + const midX = (fromPos.x + toPos.x) / 2 + const curve = `M ${fromPos.x - 12} ${fromPos.y} C ${midX} ${fromPos.y}, ${midX} ${toPos.y}, ${toPos.x + 12} ${toPos.y}` + + return ( + handleDeleteWire(wire.id)}> + {/* Wire shadow/glow */} + + {/* Main wire */} + + {/* Delete indicator on hover */} + Click to delete wire + + ) + }) + } + + // Header content with shape count and actions + const containedCount = containedShapeIds.length + const headerContent = ( +
+ + šŸ”Œ {name} + + ({containedCount} tool{containedCount !== 1 ? 's' : ''}, {wires.length} wire{wires.length !== 1 ? 's' : ''}) + + + + + +
+ ) + + return ( + + { + this.editor.updateShape({ + id: shape.id, + type: 'IOChip', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} + > + {/* Main content area with visual frame border */} +
setIsHovering(true)} + onPointerLeave={() => setIsHovering(false)} + > + {/* SVG layer for wire rendering */} + + {renderWires()} + + + {/* Dashed border to indicate drop zone */} +
+ {containedCount === 0 && !wiringMode && ( +
+
šŸ“„
+
{description || 'Drag tools here to analyze their I/O'}
+
+ )} + {wiringMode && ( +
+
šŸ”—
+
+ {wireStartPin + ? `Click a ${wireStartPin.direction === 'output' ? 'input' : 'output'} pin to complete connection` + : 'Click a pin to start wiring' + } +
+
+ )} +
+ + {/* Input pins (left side) */} + {inputPins.map((pin, index) => renderPin(pin, index, true))} + + {/* Output pins (right side) */} + {outputPins.map((pin, index) => renderPin(pin, index, false))} + + {/* Selected pin details panel */} + {selectedPin && !wiringMode && ( +
e.stopPropagation()} + > +
+ {PIN_TYPE_ICONS[selectedPin.type]} + {selectedPin.name} + + {selectedPin.type} + +
+ {selectedPin.description && ( +
+ {selectedPin.description} +
+ )} +
+ Direction: {selectedPin.direction === 'input' ? 'ā¬…ļø Input' : 'āž”ļø Output'} + {selectedPin.required && Required} + {selectedPin.connected && āœ“ Connected} +
+ +
+ )} + + {/* Save Template Dialog */} + {showSaveDialog && ( +
e.stopPropagation()} + > +
+ šŸ’¾ Save as Template +
+ +
+ + setSaveName(e.target.value)} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #e2e8f0', + borderRadius: '6px', + fontSize: '12px', + }} + placeholder="My Custom Chip" + /> +
+ +
+ +