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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user