feat: add IO Chip tool for visual I/O routing between canvas tools
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
a8c3988e3f
commit
527462acb7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<IOChipTemplate[]>([])
|
||||
const [categories, setCategories] = useState<IOChipCategory[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<IOChipTemplate | null>(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 (
|
||||
<div style={{ display: 'flex', gap: '2px', flexWrap: 'wrap' }}>
|
||||
{pins.slice(0, 5).map((pin, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 4px',
|
||||
backgroundColor: `${PIN_TYPE_COLORS[pin.type]}20`,
|
||||
color: PIN_TYPE_COLORS[pin.type],
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
title={pin.type}
|
||||
>
|
||||
{PIN_TYPE_ICONS[pin.type]}
|
||||
</span>
|
||||
))}
|
||||
{pins.length > 5 && (
|
||||
<span style={{ fontSize: '10px', color: '#94a3b8' }}>+{pins.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '600px',
|
||||
maxHeight: '80vh',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: '#1e293b' }}>
|
||||
🔌 IO Chip Templates
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#64748b' }}>
|
||||
Select a template to create a new IO chip
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div style={{ padding: '12px 20px', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: selectedCategory === null ? '#3b82f6' : '#f1f5f9',
|
||||
color: selectedCategory === null ? 'white' : '#64748b',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setSelectedCategory(cat.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
backgroundColor: selectedCategory === cat.id ? '#3b82f6' : '#f1f5f9',
|
||||
color: selectedCategory === cat.id ? 'white' : '#64748b',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{cat.icon} {cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 20px',
|
||||
}}
|
||||
>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: '#94a3b8',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '12px' }}>📦</div>
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{searchQuery ? 'No templates match your search' : 'No templates yet'}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||
Create an IO chip and save it as a template
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{filteredTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
onClick={() => 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'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1e293b' }}>
|
||||
{template.icon || '🔌'} {template.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={(e) => handleExportTemplate(template, e)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
title="Export template"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleDeleteTemplate(template.id, e)}
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: '#fef2f2',
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
title="Delete template"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{template.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
marginBottom: '10px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{template.description.slice(0, 80)}
|
||||
{template.description.length > 80 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', marginBottom: '8px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>
|
||||
Inputs
|
||||
</div>
|
||||
{renderPinPreview(template.inputPins, 'input')}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>
|
||||
Outputs
|
||||
</div>
|
||||
{renderPinPreview(template.outputPins, 'output')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{template.tags.slice(0, 3).map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: '4px',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with actions */}
|
||||
{selectedTemplate && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderTop: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '13px', color: '#64748b' }}>
|
||||
Selected: <strong>{selectedTemplate.name}</strong>
|
||||
<span style={{ marginLeft: '8px', fontSize: '11px' }}>
|
||||
({selectedTemplate.inputPins.length} inputs, {selectedTemplate.outputPins.length} outputs)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateFromTemplate(selectedTemplate)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Create IO Chip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, any> // 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<string, IOChipTemplate> = 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, 'id' | 'createdAt' | 'updatedAt'>): 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>): 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, 'id'>): 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
import { BaseBoxShapeTool, TLEventHandlers } from 'tldraw'
|
||||
|
||||
export class IOChipTool extends BaseBoxShapeTool {
|
||||
static override id = 'IOChip'
|
||||
static override initial = 'idle'
|
||||
override shapeType = 'IOChip'
|
||||
|
||||
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||
console.log('🔌 IOChipTool: IO Chip created')
|
||||
// Switch back to select tool after creating the chip
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
|||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
import { ChipTemplateBrowser } from "../components/ChipTemplateBrowser"
|
||||
|
||||
// Dark mode utilities
|
||||
const getDarkMode = (): boolean => {
|
||||
|
|
@ -49,6 +50,21 @@ export function CustomToolbar() {
|
|||
const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
|
||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||
const [isDarkMode, setIsDarkMode] = useState(getDarkMode())
|
||||
const [showChipTemplates, setShowChipTemplates] = useState(false)
|
||||
|
||||
// Listen for open-io-chip-templates event
|
||||
useEffect(() => {
|
||||
const handleOpenChipTemplates = () => {
|
||||
console.log('🔌 Opening IO Chip Template Browser')
|
||||
setShowChipTemplates(true)
|
||||
}
|
||||
|
||||
window.addEventListener('open-io-chip-templates', handleOpenChipTemplates)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('open-io-chip-templates', handleOpenChipTemplates)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initialize dark mode on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -1134,6 +1150,14 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["IOChip"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["IOChip"]}
|
||||
icon="rectangle"
|
||||
label="IO Chip"
|
||||
isSelected={tools["IOChip"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* Share Location tool removed for now */}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
|
|
@ -1160,6 +1184,14 @@ export function CustomToolbar() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* IO Chip Template Browser */}
|
||||
{showChipTemplates && (
|
||||
<ChipTemplateBrowser
|
||||
editor={editor}
|
||||
onClose={() => setShowChipTemplates(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,6 +228,15 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("Multmux"),
|
||||
},
|
||||
IOChip: {
|
||||
id: "IOChip",
|
||||
icon: "rectangle",
|
||||
label: "IO Chip",
|
||||
kbd: "alt+shift+p",
|
||||
readonlyOk: true,
|
||||
type: "IOChip",
|
||||
onSelect: () => editor.setCurrentTool("IOChip"),
|
||||
},
|
||||
hand: {
|
||||
...tools.hand,
|
||||
onDoubleClick: (info: any) => {
|
||||
|
|
@ -335,6 +344,17 @@ export const overrides: TLUiOverrides = {
|
|||
},
|
||||
readonlyOk: true,
|
||||
},
|
||||
openIOChipTemplates: {
|
||||
id: "open-io-chip-templates",
|
||||
label: "IO Chip Templates",
|
||||
kbd: "alt+t",
|
||||
onSelect: () => {
|
||||
// Dispatch event to open IO Chip Template Browser
|
||||
const event = new CustomEvent('open-io-chip-templates')
|
||||
window.dispatchEvent(event)
|
||||
},
|
||||
readonlyOk: true,
|
||||
},
|
||||
moveSelectedLeft: {
|
||||
id: "move-selected-left",
|
||||
label: "Move Left",
|
||||
|
|
|
|||
Loading…
Reference in New Issue