Initial commit: Chat-to-diagram v1.0

- Chat interface with OpenAI GPT integration
- Automatic diagram generation from text descriptions
- Tldraw canvas with Dagre layout engine
- REST API instead of WebSocket approach

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-09 23:23:56 +01:00
commit 18ba831a2a
31 changed files with 11875 additions and 0 deletions

27
src/app/globals.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

19
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Voice to Diagram - AI-Powered Diagram Generation',
description: 'Convert natural spoken descriptions into live, auto-laid-out diagrams using voice and AI',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
)
}

61
src/app/page.tsx Normal file
View File

@@ -0,0 +1,61 @@
'use client';
import { useState } from 'react';
import { Editor } from 'tldraw';
import { TldrawCanvas } from '@/components/features/TldrawCanvas';
import { ChatPanel } from '@/components/features/ChatPanel';
import { addTestShapes, generateTldrawShapes, clearCanvas } from '@/lib/tldraw-helpers';
import { getAutoLayout } from '@/lib/layout-engine';
import { mockFlowchart } from '@/lib/mock-data';
export default function Home() {
const [editor, setEditor] = useState<Editor | null>(null);
// Test button handlers
const handleAddTestShapes = () => {
if (editor) {
addTestShapes(editor);
editor.zoomToFit();
}
};
const handleGenerateGraph = () => {
if (!editor) return;
clearCanvas(editor);
const layout = getAutoLayout(mockFlowchart);
generateTldrawShapes(layout, editor);
editor.zoomToFit();
};
return (
<main className="relative w-full h-screen flex">
{/* Left Sidebar - Chat Panel */}
<div className="w-96 h-full flex-shrink-0">
<ChatPanel editor={editor} />
</div>
{/* Right Side - Tldraw Canvas */}
<div className="flex-1 h-full relative">
<TldrawCanvas onEditorMount={setEditor} />
{/* Test Buttons - positioned in top right */}
<div className="absolute top-4 right-4 z-10 flex gap-2">
<button
onClick={handleAddTestShapes}
disabled={!editor}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed shadow-lg"
>
Add Test Shapes
</button>
<button
onClick={handleGenerateGraph}
disabled={!editor}
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed shadow-lg"
>
Generate Graph
</button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useEffect, useRef } from 'react';
import { Editor } from 'tldraw';
import { useChatAPI } from '@/hooks/useChatAPI';
import { ChatMessage } from '@/components/ui/ChatMessage';
import { ChatInput } from '@/components/ui/ChatInput';
import { generateTldrawShapes, clearCanvas } from '@/lib/tldraw-helpers';
import { getAutoLayout } from '@/lib/layout-engine';
import { MessageSquare, Trash2 } from 'lucide-react';
interface ChatPanelProps {
editor: Editor | null;
}
/**
* Main chat panel component with message history and input
*/
export function ChatPanel({ editor }: ChatPanelProps) {
const { messages, isLoading, error, sendMessage, clearMessages } = useChatAPI();
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Generate diagram when AI returns diagram data
useEffect(() => {
const lastMessage = messages[messages.length - 1];
if (lastMessage?.diagramData && editor) {
console.log('Generating diagram from AI response:', lastMessage.diagramData);
// Clear canvas and generate new diagram
clearCanvas(editor);
const layout = getAutoLayout(lastMessage.diagramData);
generateTldrawShapes(layout, editor);
editor.zoomToFit();
}
}, [messages, editor]);
return (
<div className="flex flex-col h-full bg-white border-r border-gray-200">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-500" />
<h2 className="font-semibold text-gray-900">Diagram Chat</h2>
</div>
<button
onClick={clearMessages}
disabled={messages.length === 0}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Clear conversation"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<MessageSquare className="w-12 h-12 text-gray-300 mb-3" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Welcome!
</h3>
<p className="text-sm text-gray-600 max-w-sm">
Describe the diagram you want to create, and I'll generate it for you.
Try something like "Create a login flow diagram" or "Show me an e-commerce checkout process."
</p>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Error Display */}
{error && (
<div className="px-4 py-2 bg-red-50 border-t border-red-200">
<p className="text-sm text-red-600">Error: {error}</p>
</div>
)}
{/* Input Area */}
<div className="p-4 border-t border-gray-200 bg-gray-50">
<ChatInput
onSend={sendMessage}
isLoading={isLoading}
disabled={!editor}
/>
{!editor && (
<p className="text-xs text-gray-500 mt-2">
Waiting for canvas to load...
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { Tldraw, Editor } from 'tldraw';
import 'tldraw/tldraw.css';
interface TldrawCanvasProps {
onEditorMount?: (editor: Editor) => void;
}
export function TldrawCanvas({ onEditorMount }: TldrawCanvasProps) {
return (
<div className="w-full h-screen">
<Tldraw
onMount={(editor) => {
if (onEditorMount) {
onEditorMount(editor);
}
}}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useState, KeyboardEvent } from 'react';
import { Send, Loader2 } from 'lucide-react';
interface ChatInputProps {
onSend: (message: string) => void;
isLoading: boolean;
disabled?: boolean;
}
/**
* Chat input component with send button
*/
export function ChatInput({ onSend, isLoading, disabled }: ChatInputProps) {
const [input, setInput] = useState('');
const handleSend = () => {
if (input.trim() && !isLoading && !disabled) {
onSend(input.trim());
setInput('');
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex gap-2 items-end">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe the diagram you want to create..."
disabled={isLoading || disabled}
className="flex-1 min-h-[60px] max-h-[120px] p-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed text-gray-900 bg-white placeholder:text-gray-400"
rows={2}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading || disabled}
className="px-4 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import { ChatMessage as ChatMessageType } from '@/hooks/useChatAPI';
import { User, Bot, CheckCircle } from 'lucide-react';
interface ChatMessageProps {
message: ChatMessageType;
}
/**
* Individual chat message display component
*/
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user';
const isAssistant = message.role === 'assistant';
return (
<div
className={`flex gap-3 p-4 ${
isUser ? 'bg-blue-50' : 'bg-gray-50'
} rounded-lg`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
isUser ? 'bg-blue-500' : 'bg-gray-700'
}`}
>
{isUser ? (
<User className="w-5 h-5 text-white" />
) : (
<Bot className="w-5 h-5 text-white" />
)}
</div>
{/* Message Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">
{isUser ? 'You' : 'Assistant'}
</span>
<span className="text-xs text-gray-500">
{message.timestamp.toLocaleTimeString()}
</span>
{message.diagramData && (
<span className="flex items-center gap-1 text-xs text-green-600 font-medium">
<CheckCircle className="w-3 h-3" />
Diagram generated
</span>
)}
</div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words">
{/* Remove diagram markers from display */}
{message.content
.replace(/\[DIAGRAM_START\][\s\S]*?\[DIAGRAM_END\]/g, '[Diagram generated]')
.trim()}
</div>
</div>
</div>
);
}

147
src/hooks/useChatAPI.ts Normal file
View File

@@ -0,0 +1,147 @@
'use client';
import { useState, useCallback } from 'react';
import { OPENAI_CONFIG, validateOpenAIConfig } from '@/lib/openai-config';
import { DIAGRAM_GENERATION_SYSTEM_PROMPT } from '@/lib/chat-prompts';
import { extractDiagramFromResponse } from '@/lib/diagram-parser';
import { GraphModel } from '@/types/graph';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
diagramData?: GraphModel;
}
interface UseChatAPIReturn {
messages: ChatMessage[];
isLoading: boolean;
error: string | null;
sendMessage: (userMessage: string) => Promise<void>;
clearMessages: () => void;
}
/**
* Hook for managing chat conversation with OpenAI Chat Completions API
*/
export function useChatAPI(): UseChatAPIReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Send a message to OpenAI and process the response
*/
const sendMessage = useCallback(async (userMessage: string) => {
if (!validateOpenAIConfig()) {
setError('OpenAI API key not configured');
return;
}
if (!userMessage.trim()) {
return;
}
setIsLoading(true);
setError(null);
// Add user message to chat
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: userMessage,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Prepare messages for API call
const apiMessages = [
{
role: 'system',
content: DIAGRAM_GENERATION_SYSTEM_PROMPT,
},
...messages.map(msg => ({
role: msg.role,
content: msg.content,
})),
{
role: 'user',
content: userMessage,
},
];
// Call OpenAI Chat Completions API
const response = await fetch(`${OPENAI_CONFIG.apiBase}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OPENAI_CONFIG.apiKey}`,
},
body: JSON.stringify({
model: OPENAI_CONFIG.model,
messages: apiMessages,
temperature: OPENAI_CONFIG.temperature,
max_tokens: OPENAI_CONFIG.maxTokens,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Failed to get response from OpenAI');
}
const data = await response.json();
const assistantContent = data.choices[0]?.message?.content || '';
// Extract diagram if present
const diagramData = extractDiagramFromResponse(assistantContent);
// Add assistant message to chat
const assistantMsg: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: assistantContent,
timestamp: new Date(),
diagramData: diagramData || undefined,
};
setMessages(prev => [...prev, assistantMsg]);
} catch (err) {
console.error('Error sending message:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
// Add error message to chat
const errorMsg: ChatMessage = {
id: `error-${Date.now()}`,
role: 'assistant',
content: `Sorry, I encountered an error: ${errorMessage}`,
timestamp: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsLoading(false);
}
}, [messages]);
/**
* Clear all messages
*/
const clearMessages = useCallback(() => {
setMessages([]);
setError(null);
}, []);
return {
messages,
isLoading,
error,
sendMessage,
clearMessages,
};
}

46
src/lib/chat-prompts.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* Chat System Prompts for Diagram Generation
*
* Defines the AI assistant's role and response format
*/
export const DIAGRAM_GENERATION_SYSTEM_PROMPT = `You are a helpful diagram generation assistant. Your role is to understand user descriptions and create structured diagram representations.
When a user describes a process, workflow, system, or any diagram, you should:
1. Listen carefully to understand the key entities, steps, decisions, and relationships
2. Generate a structured JSON representation of the diagram
3. Use appropriate node types:
- 'start': For the beginning of a process
- 'process': For actions, steps, or operations
- 'decision': For yes/no branches or conditional logic
- 'data': For data elements or information storage
- 'end': For terminal points or final states
4. Create clear, descriptive labels for nodes and edges
5. DO NOT specify coordinates or positions - those are calculated automatically
**IMPORTANT**: When you generate a diagram, you MUST wrap the JSON in special markers:
[DIAGRAM_START]
{
"nodes": [
{"id": "node1", "label": "Start", "type": "start"},
{"id": "node2", "label": "Process Step", "type": "process"}
],
"edges": [
{"id": "edge1", "source": "node1", "target": "node2", "label": ""}
]
}
[DIAGRAM_END]
Be conversational and helpful. If the description is unclear, ask for clarification. Confirm what you understood before generating the diagram.`;
/**
* Example user prompts for testing
*/
export const EXAMPLE_PROMPTS = [
'Create a simple login process diagram',
'Show me a flowchart for making a cup of coffee',
'Design a user authentication workflow with OAuth',
'Create a diagram for a basic e-commerce checkout process',
];

99
src/lib/diagram-parser.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Diagram Parser Utility
*
* Extracts and validates diagram JSON from AI responses
*/
import { GraphModel } from '@/types/graph';
/**
* Extract diagram JSON from AI response text
* Looks for content between [DIAGRAM_START] and [DIAGRAM_END] markers
*/
export function extractDiagramFromResponse(response: string): GraphModel | null {
try {
const startMarker = '[DIAGRAM_START]';
const endMarker = '[DIAGRAM_END]';
const startIndex = response.indexOf(startMarker);
const endIndex = response.indexOf(endMarker);
if (startIndex === -1 || endIndex === -1) {
console.log('No diagram markers found in response');
return null;
}
const jsonStr = response.substring(
startIndex + startMarker.length,
endIndex
).trim();
const diagram = JSON.parse(jsonStr) as GraphModel;
// Validate diagram structure
if (!validateDiagram(diagram)) {
console.error('Invalid diagram structure');
return null;
}
return diagram;
} catch (error) {
console.error('Failed to extract diagram from response:', error);
return null;
}
}
/**
* Validate diagram structure
*/
export function validateDiagram(diagram: any): diagram is GraphModel {
if (!diagram || typeof diagram !== 'object') {
return false;
}
if (!Array.isArray(diagram.nodes) || !Array.isArray(diagram.edges)) {
return false;
}
// Validate nodes
for (const node of diagram.nodes) {
if (!node.id || !node.label || !node.type) {
console.error('Invalid node structure:', node);
return false;
}
const validTypes = ['start', 'process', 'decision', 'end', 'data', 'default'];
if (!validTypes.includes(node.type)) {
console.error('Invalid node type:', node.type);
return false;
}
}
// Validate edges
for (const edge of diagram.edges) {
if (!edge.id || !edge.source || !edge.target) {
console.error('Invalid edge structure:', edge);
return false;
}
// Check that source and target nodes exist
const sourceExists = diagram.nodes.some((n: any) => n.id === edge.source);
const targetExists = diagram.nodes.some((n: any) => n.id === edge.target);
if (!sourceExists || !targetExists) {
console.error('Edge references non-existent node:', edge);
return false;
}
}
return true;
}
/**
* Remove diagram markers from response text (for display)
*/
export function stripDiagramMarkers(response: string): string {
return response
.replace(/\[DIAGRAM_START\][\s\S]*?\[DIAGRAM_END\]/g, '[Diagram generated]')
.trim();
}

84
src/lib/layout-engine.ts Normal file
View File

@@ -0,0 +1,84 @@
import dagre from '@dagrejs/dagre';
import { GraphModel, LayoutResult, PositionedNode } from '@/types/graph';
// Default node dimensions
const NODE_WIDTH = 180;
const NODE_HEIGHT = 80;
/**
* Layout configuration options
*/
export interface LayoutOptions {
rankdir?: 'TB' | 'BT' | 'LR' | 'RL'; // Layout direction
nodesep?: number; // Horizontal spacing between nodes
ranksep?: number; // Vertical spacing between ranks
nodeWidth?: number;
nodeHeight?: number;
}
/**
* Compute automatic layout for a graph using Dagre
* @param graphModel - The semantic graph model from AI
* @param options - Layout configuration options
* @returns Layout result with positioned nodes
*/
export function getAutoLayout(
graphModel: GraphModel,
options: LayoutOptions = {}
): LayoutResult {
const {
rankdir = 'TB',
nodesep = 50,
ranksep = 100,
nodeWidth = NODE_WIDTH,
nodeHeight = NODE_HEIGHT
} = options;
// Create new Dagre graph
const graph = new dagre.graphlib.Graph();
// Configure layout
graph.setGraph({
rankdir,
nodesep,
ranksep
});
graph.setDefaultEdgeLabel(() => ({}));
// Add nodes to Dagre graph
graphModel.nodes.forEach(node => {
graph.setNode(node.id, {
label: node.label,
width: nodeWidth,
height: nodeHeight
});
});
// Add edges to Dagre graph
graphModel.edges.forEach(edge => {
graph.setEdge(edge.source, edge.target);
});
// Compute layout
dagre.layout(graph);
// Extract positioned nodes
const positionedNodes: PositionedNode[] = graphModel.nodes.map(node => {
const nodeWithPosition = graph.node(node.id);
// Dagre returns center position, we need top-left
return {
...node,
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
width: nodeWidth,
height: nodeHeight
};
});
return {
nodes: positionedNodes,
edges: graphModel.edges
};
}

83
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,83 @@
import { GraphModel } from '@/types/graph';
/**
* Mock flowchart for testing
*/
export const mockFlowchart: GraphModel = {
nodes: [
{ id: '1', label: 'Start', type: 'start' },
{ id: '2', label: 'Process Data', type: 'process' },
{ id: '3', label: 'Is Valid?', type: 'decision' },
{ id: '4', label: 'Save', type: 'process' },
{ id: '5', label: 'Error', type: 'end' },
{ id: '6', label: 'Success', type: 'end' }
],
edges: [
{ id: 'e1', source: '1', target: '2' },
{ id: 'e2', source: '2', target: '3' },
{ id: 'e3', source: '3', target: '4', label: 'Yes' },
{ id: 'e4', source: '3', target: '5', label: 'No' },
{ id: 'e5', source: '4', target: '6' }
]
};
/**
* Simple linear process diagram
*/
export const mockLinearProcess: GraphModel = {
nodes: [
{ id: '1', label: 'Step 1', type: 'start' },
{ id: '2', label: 'Step 2', type: 'process' },
{ id: '3', label: 'Step 3', type: 'process' },
{ id: '4', label: 'Step 4', type: 'end' }
],
edges: [
{ id: 'e1', source: '1', target: '2' },
{ id: 'e2', source: '2', target: '3' },
{ id: 'e3', source: '3', target: '4' }
]
};
/**
* Complex branching diagram
*/
export const mockComplexDiagram: GraphModel = {
nodes: [
{ id: '1', label: 'User Input', type: 'start' },
{ id: '2', label: 'Validate', type: 'process' },
{ id: '3', label: 'Valid?', type: 'decision' },
{ id: '4', label: 'Parse Data', type: 'process' },
{ id: '5', label: 'Transform', type: 'process' },
{ id: '6', label: 'Check Type', type: 'decision' },
{ id: '7', label: 'Type A Handler', type: 'process' },
{ id: '8', label: 'Type B Handler', type: 'process' },
{ id: '9', label: 'Type C Handler', type: 'process' },
{ id: '10', label: 'Store Result', type: 'data' },
{ id: '11', label: 'Success', type: 'end' },
{ id: '12', label: 'Error', type: 'end' }
],
edges: [
{ id: 'e1', source: '1', target: '2' },
{ id: 'e2', source: '2', target: '3' },
{ id: 'e3', source: '3', target: '4', label: 'Yes' },
{ id: 'e4', source: '3', target: '12', label: 'No' },
{ id: 'e5', source: '4', target: '5' },
{ id: 'e6', source: '5', target: '6' },
{ id: 'e7', source: '6', target: '7', label: 'Type A' },
{ id: 'e8', source: '6', target: '8', label: 'Type B' },
{ id: 'e9', source: '6', target: '9', label: 'Type C' },
{ id: 'e10', source: '7', target: '10' },
{ id: 'e11', source: '8', target: '10' },
{ id: 'e12', source: '9', target: '10' },
{ id: 'e13', source: '10', target: '11' }
]
};
/**
* All available mock diagrams
*/
export const mockDiagrams = {
flowchart: mockFlowchart,
linear: mockLinearProcess,
complex: mockComplexDiagram
};

24
src/lib/openai-config.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* OpenAI API Configuration
*
* Configuration for OpenAI Chat Completions API integration
*/
export const OPENAI_CONFIG = {
apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY || '',
apiBase: 'https://api.openai.com/v1',
model: 'gpt-4-turbo-preview',
temperature: 0.7,
maxTokens: 2000,
} as const;
/**
* Validate OpenAI configuration
*/
export function validateOpenAIConfig(): boolean {
if (!OPENAI_CONFIG.apiKey) {
console.error('OpenAI API key not found in environment variables');
return false;
}
return true;
}

143
src/lib/tldraw-helpers.ts Normal file
View File

@@ -0,0 +1,143 @@
import { Editor, createShapeId, TLGeoShape } from 'tldraw';
import { LayoutResult, PositionedNode } from '@/types/graph';
/**
* Add test shapes to the canvas for testing programmatic control
*/
export function addTestShapes(editor: Editor) {
const shapeId = createShapeId();
editor.createShape({
id: shapeId,
type: 'geo',
x: 100,
y: 100,
props: {
w: 200,
h: 100,
geo: 'rectangle',
text: 'Test Node',
fill: 'solid',
color: 'blue'
}
});
// Add a second shape with an arrow
const shapeId2 = createShapeId();
editor.createShape({
id: shapeId2,
type: 'geo',
x: 400,
y: 100,
props: {
w: 200,
h: 100,
geo: 'rectangle',
text: 'Test Node 2',
fill: 'solid',
color: 'green'
}
});
// Create an arrow between them using absolute coordinates
const arrowId = createShapeId();
editor.createShape({
id: arrowId,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: 300, y: 150 }, // End of first shape
end: { x: 400, y: 150 } // Start of second shape
}
});
}
/**
* Map node types to Tldraw geo shapes
*/
function getGeoTypeForNode(nodeType: string): TLGeoShape['props']['geo'] {
switch (nodeType) {
case 'decision':
return 'diamond';
case 'start':
case 'end':
return 'ellipse';
case 'data':
return 'trapezoid';
default:
return 'rectangle';
}
}
/**
* Generate Tldraw shapes from positioned graph nodes
*/
export function generateTldrawShapes(layout: LayoutResult, editor: Editor) {
const nodeShapeMap = new Map<string, string>();
// Create node shapes
layout.nodes.forEach(node => {
const shapeId = createShapeId();
nodeShapeMap.set(node.id, shapeId);
const geoType = getGeoTypeForNode(node.type);
editor.createShape({
id: shapeId,
type: 'geo',
x: node.x,
y: node.y,
props: {
w: node.width,
h: node.height,
geo: geoType,
text: node.label,
fill: 'solid',
color: node.type === 'start' ? 'green' : node.type === 'end' ? 'red' : 'blue'
}
});
});
// Create edge arrows with bindings
layout.edges.forEach(edge => {
const sourceShapeId = nodeShapeMap.get(edge.source);
const targetShapeId = nodeShapeMap.get(edge.target);
if (sourceShapeId && targetShapeId) {
// Get source and target nodes to calculate positions
const sourceNode = layout.nodes.find(n => n.id === edge.source);
const targetNode = layout.nodes.find(n => n.id === edge.target);
if (sourceNode && targetNode) {
const arrowId = createShapeId();
// Calculate start and end points (bottom center of source, top center of target)
const startX = sourceNode.x + sourceNode.width / 2;
const startY = sourceNode.y + sourceNode.height;
const endX = targetNode.x + targetNode.width / 2;
const endY = targetNode.y;
editor.createShape({
id: arrowId,
type: 'arrow',
x: 0,
y: 0,
props: {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
text: edge.label || ''
}
});
}
}
});
}
/**
* Clear all shapes from the canvas
*/
export function clearCanvas(editor: Editor) {
editor.selectAll();
editor.deleteShapes(editor.getSelectedShapeIds());
}

48
src/types/graph.ts Normal file
View File

@@ -0,0 +1,48 @@
// Node in the semantic graph (before layout)
export interface GraphNode {
id: string;
label: string;
type: 'process' | 'decision' | 'start' | 'end' | 'data' | 'default';
metadata?: Record<string, unknown>;
}
// Edge connecting nodes
export interface GraphEdge {
id: string;
source: string;
target: string;
label?: string;
}
// Complete graph structure from AI
export interface GraphModel {
nodes: GraphNode[];
edges: GraphEdge[];
}
// Node after Dagre layout (with coordinates)
export interface PositionedNode extends GraphNode {
x: number;
y: number;
width: number;
height: number;
}
// Layout result
export interface LayoutResult {
nodes: PositionedNode[];
edges: GraphEdge[];
}
// Application state types
export type ConnectionState = 'disconnected' | 'connecting' | 'connected';
export type RecordingState = 'idle' | 'recording';
export type ProcessingState = 'idle' | 'processing' | 'generating';
// OpenAI Realtime API event types
export interface FunctionCallEvent {
type: 'response.function_call_arguments.done';
call_id: string;
name: string;
arguments: string; // JSON string of GraphModel
}