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:
27
src/app/globals.css
Normal file
27
src/app/globals.css
Normal 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
19
src/app/layout.tsx
Normal 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
61
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/features/ChatPanel.tsx
Normal file
105
src/components/features/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/components/features/TldrawCanvas.tsx
Normal file
22
src/components/features/TldrawCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/ui/ChatInput.tsx
Normal file
56
src/components/ui/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/components/ui/ChatMessage.tsx
Normal file
62
src/components/ui/ChatMessage.tsx
Normal 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
147
src/hooks/useChatAPI.ts
Normal 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
46
src/lib/chat-prompts.ts
Normal 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
99
src/lib/diagram-parser.ts
Normal 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
84
src/lib/layout-engine.ts
Normal 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
83
src/lib/mock-data.ts
Normal 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
24
src/lib/openai-config.ts
Normal 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
143
src/lib/tldraw-helpers.ts
Normal 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
48
src/types/graph.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user