Files
next-ai-draw-io/components/chat-panel.tsx
dayuan.jiang e53f77a2a6 feat: add show/hide chat panel with Ctrl+B shortcut
- Add toggle button in chat panel header
- Implement collapsed state with thin vertical strip
- Add Ctrl+B keyboard shortcut to toggle visibility
- Canvas expands to full width when chat is hidden
- Smooth 300ms transition animation
2025-11-15 12:09:32 +09:00

271 lines
9.5 KiB
TypeScript

"use client";
import type React from "react";
import { useRef, useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { ChatInput } from "@/components/chat-input";
import { ChatMessageDisplay } from "./chat-message-display";
import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
interface ChatPanelProps {
isVisible: boolean;
onToggleVisibility: () => void;
}
export default function ChatPanel({ isVisible, onToggleVisibility }: ChatPanelProps) {
const {
loadDiagram: onDisplayChart,
handleExport: onExport,
resolverRef,
chartXML,
clearDiagram,
} = useDiagram();
const onFetchChart = () => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve;
}
onExport();
}),
new Promise<string>((_, reject) =>
setTimeout(() => reject(new Error("Chart export timed out after 10 seconds")), 10000)
)
]);
};
// Add a step counter to track updates
// Add state for file attachments
const [files, setFiles] = useState<File[]>([]);
// Add state for showing the history dialog
const [showHistory, setShowHistory] = useState(false);
// Convert File[] to FileList for experimental_attachments
const createFileList = (files: File[]): FileList => {
const dt = new DataTransfer();
files.forEach((file) => dt.items.add(file));
return dt.files;
};
// Add state for input management
const [input, setInput] = useState("");
// Remove the currentXmlRef and related useEffect
const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
// Diagram is handled streamingly in the ChatMessageDisplay component
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: "Successfully displayed the diagram.",
});
} else if (toolCall.toolName === "edit_diagram") {
const { edits } = toolCall.input as {
edits: Array<{ search: string; replace: string }>;
};
let currentXml = '';
try {
// Fetch current chart XML
currentXml = await onFetchChart();
// Apply edits using the utility function
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
// Load the edited diagram
onDisplayChart(editedXml);
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
});
} catch (error) {
console.error("Edit diagram failed:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Provide detailed error with current diagram XML
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Edit failed: ${errorMessage}
Current diagram XML:
\`\`\`xml
${currentXml}
\`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
});
}
}
},
onError: (error) => {
console.error("Chat error:", error);
},
});
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (input.trim() && status !== "streaming") {
try {
// Fetch chart data before sending message
let chartXml = await onFetchChart();
// Format the XML to ensure consistency
chartXml = formatXML(chartXml);
// Create message parts
const parts: any[] = [{ type: "text", text: input }];
// Add file parts if files exist
if (files.length > 0) {
for (const file of files) {
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () =>
resolve(reader.result as string);
reader.readAsDataURL(file);
});
parts.push({
type: "file",
url: dataUrl,
mediaType: file.type,
});
}
}
sendMessage(
{ parts },
{
body: {
xml: chartXml,
},
}
);
// Clear input and files after submission
setInput("");
setFiles([]);
} catch (error) {
console.error("Error fetching chart data:", error);
}
}
};
// Handle input change
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setInput(e.target.value);
};
// Helper function to handle file changes
const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles);
};
// Collapsed view when chat is hidden
if (!isVisible) {
return (
<Card className="h-full flex flex-col rounded-none py-0 gap-0 items-center justify-start pt-4">
<ButtonWithTooltip
tooltipContent="Show chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
>
<PanelRightOpen className="h-5 w-5" />
</ButtonWithTooltip>
<div
className="text-sm text-gray-500 mt-8"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
Chat
</div>
</Card>
);
}
// Full view when chat is visible
return (
<Card className="h-full flex flex-col rounded-none py-0 gap-0">
<CardHeader className="p-4 flex flex-row justify-between items-center">
<CardTitle>Next-AI-Drawio</CardTitle>
<div className="flex items-center gap-2">
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
>
<PanelRightClose className="h-5 w-5" />
</ButtonWithTooltip>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<FaGithub className="w-6 h-6" />
</a>
</div>
</CardHeader>
<CardContent className="flex-grow overflow-hidden px-2">
<ChatMessageDisplay
messages={messages}
error={error}
setInput={setInput}
setFiles={handleFileChange}
/>
</CardContent>
<CardFooter className="p-2">
<ChatInput
input={input}
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={() => {
setMessages([]);
clearDiagram();
}}
files={files}
onFileChange={handleFileChange}
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
</CardFooter>
</Card>
);
}