chore: add Biome for formatting and linting (#116)

- Add Biome as formatter and linter (replaces Prettier)
- Configure Husky + lint-staged for pre-commit hooks
- Add VS Code settings for format on save
- Ignore components/ui/ (shadcn generated code)
- Remove semicolons, use 4-space indent
- Reformat all files to new style
This commit is contained in:
Dayuan Jiang
2025-12-06 12:46:40 +09:00
committed by GitHub
parent 215a101f54
commit 150eb1ff63
41 changed files with 3992 additions and 2401 deletions

View File

@@ -1,19 +1,19 @@
import React from "react";
import { Button, buttonVariants } from "@/components/ui/button";
import type { VariantProps } from "class-variance-authority"
import type React from "react"
import { Button, type buttonVariants } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type VariantProps } from "class-variance-authority";
} from "@/components/ui/tooltip"
interface ButtonWithTooltipProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
tooltipContent: string;
children: React.ReactNode;
asChild?: boolean;
tooltipContent: string
children: React.ReactNode
asChild?: boolean
}
export function ButtonWithTooltip({
@@ -27,8 +27,10 @@ export function ButtonWithTooltip({
<TooltipTrigger asChild>
<Button {...buttonProps}>{children}</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-wrap">{tooltipContent}</TooltipContent>
<TooltipContent className="max-w-xs text-wrap">
{tooltipContent}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
)
}

View File

@@ -1,12 +1,12 @@
"use client";
"use client"
import { Zap, Cloud, GitBranch, Palette } from "lucide-react";
import { Cloud, GitBranch, Palette, Zap } from "lucide-react"
interface ExampleCardProps {
icon: React.ReactNode;
title: string;
description: string;
onClick: () => void;
icon: React.ReactNode
title: string
description: string
onClick: () => void
}
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
@@ -29,43 +29,43 @@ function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
</div>
</div>
</button>
);
)
}
export default function ExamplePanel({
setInput,
setFiles,
}: {
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
setInput: (input: string) => void
setFiles: (files: File[]) => void
}) {
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.");
setInput("Replicate this flowchart.")
try {
const response = await fetch("/example.png");
const blob = await response.blob();
const file = new File([blob], "example.png", { type: "image/png" });
setFiles([file]);
const response = await fetch("/example.png")
const blob = await response.blob()
const file = new File([blob], "example.png", { type: "image/png" })
setFiles([file])
} catch (error) {
console.error("Error loading example image:", error);
console.error("Error loading example image:", error)
}
};
}
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style");
setInput("Replicate this in aws style")
try {
const response = await fetch("/architecture.png");
const blob = await response.blob();
const response = await fetch("/architecture.png")
const blob = await response.blob()
const file = new File([blob], "architecture.png", {
type: "image/png",
});
setFiles([file]);
})
setFiles([file])
} catch (error) {
console.error("Error loading architecture image:", error);
console.error("Error loading architecture image:", error)
}
};
}
return (
<div className="py-6 px-2 animate-fade-in">
@@ -75,7 +75,8 @@ export default function ExamplePanel({
Create diagrams with AI
</h2>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Describe what you want to create or upload an image to replicate
Describe what you want to create or upload an image to
replicate
</p>
</div>
@@ -91,7 +92,9 @@ export default function ExamplePanel({
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
onClick={() => {
setInput("Give me a **animated connector** diagram of transformer's architecture")
setInput(
"Give me a **animated connector** diagram of transformer's architecture",
)
setFiles([])
}}
/>
@@ -126,5 +129,5 @@ export default function ExamplePanel({
</p>
</div>
</div>
);
)
}

View File

@@ -1,10 +1,24 @@
"use client";
"use client"
import React, { useCallback, useRef, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ResetWarningModal } from "@/components/reset-warning-modal";
import { SaveDialog } from "@/components/save-dialog";
import {
Download,
History,
Image as ImageIcon,
LayoutGrid,
Loader2,
PenTool,
Send,
Trash2,
} from "lucide-react"
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ErrorToast } from "@/components/error-toast"
import { HistoryDialog } from "@/components/history-dialog"
import { ResetWarningModal } from "@/components/reset-warning-modal"
import { SaveDialog } from "@/components/save-dialog"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
@@ -12,103 +26,105 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Loader2,
Send,
Trash2,
Image as ImageIcon,
History,
Download,
PenTool,
LayoutGrid,
} from "lucide-react";
import { toast } from "sonner";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context";
import { HistoryDialog } from "@/components/history-dialog";
import { ErrorToast } from "@/components/error-toast";
} from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { useDiagram } from "@/contexts/diagram-context"
import { FilePreviewList } from "./file-preview-list"
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILES = 5;
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`;
return `${mb.toFixed(2)}MB`;
const mb = bytes / 1024 / 1024
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`
return `${mb.toFixed(2)}MB`
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
{ duration: 5000 }
);
(t) => (
<ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />
),
{ duration: 5000 },
)
}
interface ValidationResult {
validFiles: File[];
errors: string[];
validFiles: File[]
errors: string[]
}
function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
const errors: string[] = [];
const validFiles: File[] = [];
function validateFiles(
newFiles: File[],
existingCount: number,
): ValidationResult {
const errors: string[] = []
const validFiles: File[] = []
const availableSlots = MAX_FILES - existingCount;
const availableSlots = MAX_FILES - existingCount
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`);
return { validFiles, errors };
errors.push(`Maximum ${MAX_FILES} files allowed`)
return { validFiles, errors }
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`);
break;
errors.push(`Only ${availableSlots} more file(s) allowed`)
break
}
if (file.size > MAX_FILE_SIZE) {
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
errors.push(
`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`,
)
} else {
validFiles.push(file);
validFiles.push(file)
}
}
return { validFiles, errors };
return { validFiles, errors }
}
function showValidationErrors(errors: string[]) {
if (errors.length === 0) return;
if (errors.length === 0) return
if (errors.length === 1) {
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
showErrorToast(
<span className="text-muted-foreground">{errors[0]}</span>,
)
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">{errors.length} files rejected:</span>
<span className="font-medium">
{errors.length} files rejected:
</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
{errors.slice(0, 3).map((err, i) => (
<li key={i}>{err}</li>
))}
{errors.length > 3 && (
<li>...and {errors.length - 3} more</li>
)}
</ul>
</div>
);
</div>,
)
}
}
interface ChatInputProps {
input: string;
status: "submitted" | "streaming" | "ready" | "error";
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onClearChat: () => void;
files?: File[];
onFileChange?: (files: File[]) => void;
showHistory?: boolean;
onToggleHistory?: (show: boolean) => void;
sessionId?: string;
error?: Error | null;
drawioUi?: "min" | "sketch";
onToggleDrawioUi?: () => void;
input: string
status: "submitted" | "streaming" | "ready" | "error"
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
onClearChat: () => void
files?: File[]
onFileChange?: (files: File[]) => void
showHistory?: boolean
onToggleHistory?: (show: boolean) => void
sessionId?: string
error?: Error | null
drawioUi?: "min" | "sketch"
onToggleDrawioUi?: () => void
}
export function ChatInput({
@@ -126,128 +142,133 @@ export function ChatInput({
drawioUi = "min",
onToggleDrawioUi = () => {},
}: ChatInputProps) {
const { diagramHistory, saveDiagramToFile } = useDiagram();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showThemeWarning, setShowThemeWarning] = useState(false);
const { diagramHistory, saveDiagramToFile } = useDiagram()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [showClearDialog, setShowClearDialog] = useState(false)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [showThemeWarning, setShowThemeWarning] = useState(false)
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled =
(status === "streaming" || status === "submitted") && !error;
(status === "streaming" || status === "submitted") && !error
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}
}, []);
}, [])
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
adjustTextareaHeight()
}, [input, adjustTextareaHeight])
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
const form = e.currentTarget.closest("form");
e.preventDefault()
const form = e.currentTarget.closest("form")
if (form && input.trim() && !isDisabled) {
form.requestSubmit();
form.requestSubmit()
}
}
};
}
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return;
if (isDisabled) return
const items = e.clipboardData.items;
const items = e.clipboardData.items
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/")
);
item.type.startsWith("image/"),
)
if (imageItems.length > 0) {
const imageFiles = (await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile();
if (!file) return null;
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }
);
})
)).filter((f): f is File => f !== null);
const imageFiles = (
await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile()
if (!file) return null
return new File(
[file],
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type },
)
}),
)
).filter((f): f is File => f !== null)
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
const { validFiles, errors } = validateFiles(
imageFiles,
files.length,
)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
onFileChange([...files, ...validFiles])
}
}
};
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []);
const { validFiles, errors } = validateFiles(newFiles, files.length);
showValidationErrors(errors);
const newFiles = Array.from(e.target.files || [])
const { validFiles, errors } = validateFiles(newFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
onFileChange([...files, ...validFiles])
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = ""
}
};
}
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove));
onFileChange(files.filter((file) => file !== fileToRemove))
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = ""
}
};
}
const triggerFileInput = () => {
fileInputRef.current?.click();
};
fileInputRef.current?.click()
}
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
if (isDisabled) return;
if (isDisabled) return
const droppedFiles = e.dataTransfer.files;
const droppedFiles = e.dataTransfer.files
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/")
);
file.type.startsWith("image/"),
)
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
const { validFiles, errors } = validateFiles(imageFiles, files.length)
showValidationErrors(errors)
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
onFileChange([...files, ...validFiles])
}
};
}
const handleClear = () => {
onClearChat();
setShowClearDialog(false);
};
onClearChat()
setShowClearDialog(false)
}
return (
<form
@@ -316,7 +337,11 @@ export function ChatInput({
variant="ghost"
size="sm"
onClick={() => setShowThemeWarning(true)}
tooltipContent={drawioUi === "min" ? "Switch to Sketch theme" : "Switch to Minimal theme"}
tooltipContent={
drawioUi === "min"
? "Switch to Sketch theme"
: "Switch to Minimal theme"
}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{drawioUi === "min" ? (
@@ -326,27 +351,33 @@ export function ChatInput({
)}
</ButtonWithTooltip>
<Dialog open={showThemeWarning} onOpenChange={setShowThemeWarning}>
<Dialog
open={showThemeWarning}
onOpenChange={setShowThemeWarning}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Switch Theme?</DialogTitle>
<DialogDescription>
Switching themes will reload the diagram editor and clear any unsaved changes.
Switching themes will reload the diagram
editor and clear any unsaved changes.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowThemeWarning(false)}
onClick={() =>
setShowThemeWarning(false)
}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
onClearChat();
onToggleDrawioUi();
setShowThemeWarning(false);
onClearChat()
onToggleDrawioUi()
setShowThemeWarning(false)
}}
>
Switch Theme
@@ -439,5 +470,5 @@ export function ChatInput({
</div>
</div>
</form>
);
)
}

View File

@@ -1,25 +1,45 @@
"use client";
"use client"
import { useRef, useEffect, useState, useCallback } from "react";
import Image from "next/image";
import ReactMarkdown from "react-markdown";
import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai";
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus, ThumbsUp, ThumbsDown, RotateCcw, Pencil } from "lucide-react";
import { CodeBlock } from "./code-block";
import type { UIMessage } from "ai"
import {
Check,
ChevronDown,
ChevronUp,
Copy,
Cpu,
Minus,
Pencil,
Plus,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import Image from "next/image"
import { useCallback, useEffect, useRef, useState } from "react"
import ReactMarkdown from "react-markdown"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
convertToLegalXml,
replaceNodes,
validateMxCellStructure,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
import { CodeBlock } from "./code-block"
interface EditPair {
search: string;
replace: string;
search: string
replace: string
}
function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
return (
<div className="space-y-3">
{edits.map((edit, index) => (
<div key={index} className="rounded-lg border border-border/50 overflow-hidden bg-background/50">
<div
key={index}
className="rounded-lg border border-border/50 overflow-hidden bg-background/50"
>
<div className="px-3 py-1.5 bg-muted/40 border-b border-border/30 flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground">
Change {index + 1}
@@ -30,7 +50,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Minus className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">Remove</span>
<span className="text-[10px] font-medium text-red-600 uppercase tracking-wide">
Remove
</span>
</div>
<pre className="text-[11px] font-mono text-red-700 bg-red-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.search}
@@ -40,7 +62,9 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1.5">
<Plus className="w-3 h-3 text-green-500" />
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">Add</span>
<span className="text-[10px] font-medium text-green-600 uppercase tracking-wide">
Add
</span>
</div>
<pre className="text-[11px] font-mono text-green-700 bg-green-50 rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{edit.replace}
@@ -50,26 +74,26 @@ function EditDiffDisplay({ edits }: { edits: EditPair[] }) {
</div>
))}
</div>
);
)
}
import { useDiagram } from "@/contexts/diagram-context";
import { useDiagram } from "@/contexts/diagram-context"
const getMessageTextContent = (message: UIMessage): string => {
if (!message.parts) return "";
if (!message.parts) return ""
return message.parts
.filter((part: any) => part.type === "text")
.map((part: any) => part.text)
.join("\n");
};
.join("\n")
}
interface ChatMessageDisplayProps {
messages: UIMessage[];
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
sessionId?: string;
onRegenerate?: (messageIndex: number) => void;
onEditMessage?: (messageIndex: number, newText: string) => void;
messages: UIMessage[]
setInput: (input: string) => void
setFiles: (files: File[]) => void
sessionId?: string
onRegenerate?: (messageIndex: number) => void
onEditMessage?: (messageIndex: number, newText: string) => void
}
export function ChatMessageDisplay({
@@ -80,43 +104,47 @@ export function ChatMessageDisplay({
onRegenerate,
onEditMessage,
}: ChatMessageDisplayProps) {
const { chartXML, loadDiagram: onDisplayChart } = useDiagram();
const messagesEndRef = useRef<HTMLDivElement>(null);
const previousXML = useRef<string>("");
const processedToolCalls = useRef<Set<string>>(new Set());
const { chartXML, loadDiagram: onDisplayChart } = useDiagram()
const messagesEndRef = useRef<HTMLDivElement>(null)
const previousXML = useRef<string>("")
const processedToolCalls = useRef<Set<string>>(new Set())
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
{}
);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({});
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editText, setEditText] = useState<string>("");
{},
)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [copyFailedMessageId, setCopyFailedMessageId] = useState<
string | null
>(null)
const [feedback, setFeedback] = useState<Record<string, "good" | "bad">>({})
const [editingMessageId, setEditingMessageId] = useState<string | null>(
null,
)
const [editText, setEditText] = useState<string>("")
const copyMessageToClipboard = async (messageId: string, text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedMessageId(messageId);
setTimeout(() => setCopiedMessageId(null), 2000);
await navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
} catch (err) {
console.error("Failed to copy message:", err);
setCopyFailedMessageId(messageId);
setTimeout(() => setCopyFailedMessageId(null), 2000);
console.error("Failed to copy message:", err)
setCopyFailedMessageId(messageId)
setTimeout(() => setCopyFailedMessageId(null), 2000)
}
};
}
const submitFeedback = async (messageId: string, value: "good" | "bad") => {
// Toggle off if already selected
if (feedback[messageId] === value) {
setFeedback((prev) => {
const next = { ...prev };
delete next[messageId];
return next;
});
return;
const next = { ...prev }
delete next[messageId]
return next
})
return
}
setFeedback((prev) => ({ ...prev, [messageId]: value }));
setFeedback((prev) => ({ ...prev, [messageId]: value }))
try {
await fetch("/api/log-feedback", {
@@ -127,49 +155,52 @@ export function ChatMessageDisplay({
feedback: value,
sessionId,
}),
});
})
} catch (error) {
console.warn("Failed to log feedback:", error);
console.warn("Failed to log feedback:", error)
}
};
}
const handleDisplayChart = useCallback(
(xml: string) => {
const currentXml = xml || "";
const convertedXml = convertToLegalXml(currentXml);
const currentXml = xml || ""
const convertedXml = convertToLegalXml(currentXml)
if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml);
const replacedXML = replaceNodes(chartXML, convertedXml)
const validationError = validateMxCellStructure(replacedXML);
const validationError = validateMxCellStructure(replacedXML)
if (!validationError) {
previousXML.current = convertedXml;
onDisplayChart(replacedXML);
previousXML.current = convertedXml
onDisplayChart(replacedXML)
} else {
console.log("[ChatMessageDisplay] XML validation failed:", validationError);
console.log(
"[ChatMessageDisplay] XML validation failed:",
validationError,
)
}
}
},
[chartXML, onDisplayChart]
);
[chartXML, onDisplayChart],
)
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages]);
}, [messages])
useEffect(() => {
messages.forEach((message) => {
if (message.parts) {
message.parts.forEach((part: any) => {
if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part;
const { toolCallId, state } = part
if (state === "output-available") {
setExpandedTools((prev) => ({
...prev,
[toolCallId]: false,
}));
}))
}
if (
@@ -180,44 +211,44 @@ export function ChatMessageDisplay({
state === "input-streaming" ||
state === "input-available"
) {
handleDisplayChart(part.input.xml);
handleDisplayChart(part.input.xml)
} else if (
state === "output-available" &&
!processedToolCalls.current.has(toolCallId)
) {
handleDisplayChart(part.input.xml);
processedToolCalls.current.add(toolCallId);
handleDisplayChart(part.input.xml)
processedToolCalls.current.add(toolCallId)
}
}
}
});
})
}
});
}, [messages, handleDisplayChart]);
})
}, [messages, handleDisplayChart])
const renderToolPart = (part: any) => {
const callId = part.toolCallId;
const { state, input, output } = part;
const isExpanded = expandedTools[callId] ?? true;
const toolName = part.type?.replace("tool-", "");
const callId = part.toolCallId
const { state, input, output } = part
const isExpanded = expandedTools[callId] ?? true
const toolName = part.type?.replace("tool-", "")
const toggleExpanded = () => {
setExpandedTools((prev) => ({
...prev,
[callId]: !isExpanded,
}));
};
}))
}
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram";
return "Generate Diagram"
case "edit_diagram":
return "Edit Diagram";
return "Edit Diagram"
default:
return name;
return name
}
};
}
return (
<div
@@ -265,10 +296,16 @@ export function ChatMessageDisplay({
<div className="px-4 py-3 border-t border-border/40 bg-muted/20">
{typeof input === "object" && input.xml ? (
<CodeBlock code={input.xml} language="xml" />
) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
) : typeof input === "object" &&
input.edits &&
Array.isArray(input.edits) ? (
<EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && Object.keys(input).length > 0 ? (
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
) : typeof input === "object" &&
Object.keys(input).length > 0 ? (
<CodeBlock
code={JSON.stringify(input, null, 2)}
language="json"
/>
) : null}
</div>
)}
@@ -278,8 +315,8 @@ export function ChatMessageDisplay({
</div>
)}
</div>
);
};
)
}
return (
<ScrollArea className="h-full w-full scrollbar-thin">
@@ -288,72 +325,122 @@ export function ChatMessageDisplay({
) : (
<div className="py-4 px-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
const isLastAssistantMessage = message.role === "assistant" && (
messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "assistant")
);
const isLastUserMessage = message.role === "user" && (
messageIndex === messages.length - 1 ||
messages.slice(messageIndex + 1).every(m => m.role !== "user")
);
const isEditing = editingMessageId === message.id;
const userMessageText =
message.role === "user"
? getMessageTextContent(message)
: ""
const isLastAssistantMessage =
message.role === "assistant" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "assistant"))
const isLastUserMessage =
message.role === "user" &&
(messageIndex === messages.length - 1 ||
messages
.slice(messageIndex + 1)
.every((m) => m.role !== "user"))
const isEditing = editingMessageId === message.id
return (
<div
key={message.id}
className={`flex w-full ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }}
style={{
animationDelay: `${messageIndex * 50}ms`,
}}
>
{message.role === "user" && userMessageText && !isEditing && (
<div className="flex items-center gap-1 self-center mr-2">
{/* Edit button - only on last user message */}
{onEditMessage && isLastUserMessage && (
{message.role === "user" &&
userMessageText &&
!isEditing && (
<div className="flex items-center gap-1 self-center mr-2">
{/* Edit button - only on last user message */}
{onEditMessage &&
isLastUserMessage && (
<button
onClick={() => {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => {
setEditingMessageId(message.id);
setEditText(userMessageText);
}}
onClick={() =>
copyMessageToClipboard(
message.id,
userMessageText,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title="Edit message"
title={
copiedMessageId ===
message.id
? "Copied!"
: copyFailedMessageId ===
message.id
? "Failed to copy"
: "Copy message"
}
>
<Pencil className="h-3.5 w-3.5" />
{copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId ===
message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
)}
<button
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors"
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
>
{copiedMessageId === message.id ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : copyFailedMessageId === message.id ? (
<X className="h-3.5 w-3.5 text-red-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
</div>
)}
<div className="max-w-[85%] min-w-0">
{/* Edit mode for user messages */}
{isEditing && message.role === "user" ? (
<div className="flex flex-col gap-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onChange={(e) =>
setEditText(e.target.value)
}
className="w-full min-w-[300px] px-4 py-3 text-sm rounded-2xl border border-primary bg-background text-foreground resize-none focus:outline-none focus:ring-2 focus:ring-primary"
rows={Math.min(editText.split('\n').length + 1, 6)}
rows={Math.min(
editText.split("\n")
.length + 1,
6,
)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
setEditingMessageId(null);
setEditText("");
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (editText.trim() && onEditMessage) {
onEditMessage(messageIndex, editText.trim());
setEditingMessageId(null);
setEditText("");
setEditingMessageId(
null,
)
setEditText("")
} else if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey)
) {
e.preventDefault()
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
}
}
}}
@@ -361,8 +448,10 @@ export function ChatMessageDisplay({
<div className="flex justify-end gap-2">
<button
onClick={() => {
setEditingMessageId(null);
setEditText("");
setEditingMessageId(
null,
)
setEditText("")
}}
className="px-3 py-1.5 text-xs rounded-lg bg-muted hover:bg-muted/80 transition-colors"
>
@@ -370,10 +459,18 @@ export function ChatMessageDisplay({
</button>
<button
onClick={() => {
if (editText.trim() && onEditMessage) {
onEditMessage(messageIndex, editText.trim());
setEditingMessageId(null);
setEditText("");
if (
editText.trim() &&
onEditMessage
) {
onEditMessage(
messageIndex,
editText.trim(),
)
setEditingMessageId(
null,
)
setEditText("")
}
}}
disabled={!editText.trim()}
@@ -385,100 +482,172 @@ export function ChatMessageDisplay({
</div>
) : (
/* Text content in bubble */
message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
message.parts?.some(
(part: any) =>
part.type === "text" ||
part.type === "file",
) && (
<div
className={`px-4 py-3 text-sm leading-relaxed ${
message.role === "user"
? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
: message.role === "system"
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
: message.role ===
"system"
? "bg-destructive/10 text-destructive border border-destructive/20 rounded-2xl rounded-bl-md"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
} ${message.role === "user" && isLastUserMessage && onEditMessage ? "cursor-pointer hover:opacity-90 transition-opacity" : ""}`}
onClick={() => {
if (message.role === "user" && isLastUserMessage && onEditMessage) {
setEditingMessageId(message.id);
setEditText(userMessageText);
if (
message.role ===
"user" &&
isLastUserMessage &&
onEditMessage
) {
setEditingMessageId(
message.id,
)
setEditText(
userMessageText,
)
}
}}
title={message.role === "user" && isLastUserMessage && onEditMessage ? "Click to edit" : undefined}
title={
message.role === "user" &&
isLastUserMessage &&
onEditMessage
? "Click to edit"
: undefined
}
>
{message.parts?.map((part: any, index: number) => {
switch (part.type) {
case "text":
return (
<div key={index} className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role === "user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert"
}`}>
<ReactMarkdown>{part.text}</ReactMarkdown>
</div>
);
case "file":
return (
<div key={index} className="mt-2">
<Image
src={part.url}
width={200}
height={200}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit: "contain",
}}
/>
</div>
);
default:
return null;
}
})}
{message.parts?.map(
(
part: any,
index: number,
) => {
switch (part.type) {
case "text":
return (
<div
key={
index
}
className={`prose prose-sm max-w-none break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${
message.role ===
"user"
? "[&_*]:!text-primary-foreground prose-code:bg-white/20"
: "dark:prose-invert"
}`}
>
<ReactMarkdown>
{
part.text
}
</ReactMarkdown>
</div>
)
case "file":
return (
<div
key={
index
}
className="mt-2"
>
<Image
src={
part.url
}
width={
200
}
height={
200
}
alt={`Uploaded diagram or image for AI analysis`}
className="rounded-lg border border-white/20"
style={{
objectFit:
"contain",
}}
/>
</div>
)
default:
return null
}
},
)}
</div>
)
)}
{/* Tool calls outside bubble */}
{message.parts?.map((part: any) => {
if (part.type?.startsWith("tool-")) {
return renderToolPart(part);
return renderToolPart(part)
}
return null;
return null
})}
{/* Action buttons for assistant messages */}
{message.role === "assistant" && (
<div className="flex items-center gap-1 mt-2">
{/* Copy button */}
<button
onClick={() => copyMessageToClipboard(message.id, getMessageTextContent(message))}
onClick={() =>
copyMessageToClipboard(
message.id,
getMessageTextContent(
message,
),
)
}
className={`p-1.5 rounded-lg transition-colors ${
copiedMessageId === message.id
copiedMessageId ===
message.id
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-foreground hover:bg-muted"
}`}
title={copiedMessageId === message.id ? "Copied!" : "Copy response"}
title={
copiedMessageId ===
message.id
? "Copied!"
: "Copy response"
}
>
{copiedMessageId === message.id ? (
{copiedMessageId ===
message.id ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
{/* Regenerate button - only on last assistant message */}
{onRegenerate && isLastAssistantMessage && (
<button
onClick={() => onRegenerate(messageIndex)}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{onRegenerate &&
isLastAssistantMessage && (
<button
onClick={() =>
onRegenerate(
messageIndex,
)
}
className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"
title="Regenerate response"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
{/* Divider */}
<div className="w-px h-4 bg-border mx-1" />
{/* Thumbs up */}
<button
onClick={() => submitFeedback(message.id, "good")}
onClick={() =>
submitFeedback(
message.id,
"good",
)
}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "good"
feedback[message.id] ===
"good"
? "text-green-600 bg-green-100"
: "text-muted-foreground/60 hover:text-green-600 hover:bg-green-50"
}`}
@@ -488,9 +657,15 @@ export function ChatMessageDisplay({
</button>
{/* Thumbs down */}
<button
onClick={() => submitFeedback(message.id, "bad")}
onClick={() =>
submitFeedback(
message.id,
"bad",
)
}
className={`p-1.5 rounded-lg transition-colors ${
feedback[message.id] === "bad"
feedback[message.id] ===
"bad"
? "text-red-600 bg-red-100"
: "text-muted-foreground/60 hover:text-red-600 hover:bg-red-50"
}`}
@@ -502,11 +677,11 @@ export function ChatMessageDisplay({
)}
</div>
</div>
);
)
})}
</div>
)}
<div ref={messagesEndRef} />
</ScrollArea>
);
)
}

View File

@@ -1,37 +1,36 @@
"use client";
"use client"
import type React from "react";
import { useRef, useEffect, useState } from "react";
import { flushSync } from "react-dom";
import { FaGithub } from "react-icons/fa";
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
CheckCircle,
PanelRightClose,
PanelRightOpen,
Settings,
CheckCircle,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
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, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { flushSync } from "react-dom"
import { FaGithub } from "react-icons/fa"
import { Toaster } from "sonner"
import { ButtonWithTooltip } from "@/components/button-with-tooltip"
import { ChatInput } from "@/components/chat-input"
import {
SettingsDialog,
STORAGE_ACCESS_CODE_KEY,
} from "@/components/settings-dialog";
} from "@/components/settings-dialog"
import { useDiagram } from "@/contexts/diagram-context"
import { formatXML, replaceNodes, validateMxCellStructure } from "@/lib/utils"
import { ChatMessageDisplay } from "./chat-message-display"
interface ChatPanelProps {
isVisible: boolean;
onToggleVisibility: () => void;
drawioUi: "min" | "sketch";
onToggleDrawioUi: () => void;
isMobile?: boolean;
isVisible: boolean
onToggleVisibility: () => void
drawioUi: "min" | "sketch"
onToggleDrawioUi: () => void
isMobile?: boolean
}
export default function ChatPanel({
@@ -48,59 +47,61 @@ export default function ChatPanel({
resolverRef,
chartXML,
clearDiagram,
} = useDiagram();
} = useDiagram()
const onFetchChart = (saveToHistory = true) => {
return Promise.race([
new Promise<string>((resolve) => {
if (resolverRef && "current" in resolverRef) {
resolverRef.current = resolve;
resolverRef.current = resolve
}
if (saveToHistory) {
onExport();
onExport()
} else {
handleExportWithoutHistory();
handleExportWithoutHistory()
}
}),
new Promise<string>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Chart export timed out after 10 seconds")
new Error(
"Chart export timed out after 10 seconds",
),
),
10000
)
10000,
),
),
]);
};
])
}
const [files, setFiles] = useState<File[]>([]);
const [showHistory, setShowHistory] = useState(false);
const [showSettingsDialog, setShowSettingsDialog] = useState(false);
const [accessCodeRequired, setAccessCodeRequired] = useState(false);
const [input, setInput] = useState("");
const [files, setFiles] = useState<File[]>([])
const [showHistory, setShowHistory] = useState(false)
const [showSettingsDialog, setShowSettingsDialog] = useState(false)
const [accessCodeRequired, setAccessCodeRequired] = useState(false)
const [input, setInput] = useState("")
// Check if access code is required on mount
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((data) => setAccessCodeRequired(data.accessCodeRequired))
.catch(() => setAccessCodeRequired(false));
}, []);
.catch(() => setAccessCodeRequired(false))
}, [])
// Generate a unique session ID for Langfuse tracing
const [sessionId, setSessionId] = useState(
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
);
() => `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
)
// Store XML snapshots for each user message (keyed by message index)
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map());
const xmlSnapshotsRef = useRef<Map<number, string>>(new Map())
// Ref to track latest chartXML for use in callbacks (avoids stale closure)
const chartXMLRef = useRef(chartXML);
const chartXMLRef = useRef(chartXML)
useEffect(() => {
chartXMLRef.current = chartXML;
}, [chartXML]);
chartXMLRef.current = chartXML
}, [chartXML])
const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({
@@ -109,71 +110,71 @@ export default function ChatPanel({
}),
async onToolCall({ toolCall }) {
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string };
const { xml } = toolCall.input as { xml: string }
const validationError = validateMxCellStructure(xml);
const validationError = validateMxCellStructure(xml)
if (validationError) {
addToolResult({
tool: "display_diagram",
toolCallId: toolCall.toolCallId,
output: validationError,
});
})
} else {
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 }>;
};
edits: Array<{ search: string; replace: string }>
}
let currentXml = "";
let currentXml = ""
try {
console.log("[edit_diagram] Starting...");
console.log("[edit_diagram] Starting...")
// Use chartXML from ref directly - more reliable than export
// especially on Vercel where DrawIO iframe may have latency issues
// Using ref to avoid stale closure in callback
const cachedXML = chartXMLRef.current;
const cachedXML = chartXMLRef.current
if (cachedXML) {
currentXml = cachedXML;
currentXml = cachedXML
console.log(
"[edit_diagram] Using cached chartXML, length:",
currentXml.length
);
currentXml.length,
)
} else {
// Fallback to export only if no cached XML
console.log(
"[edit_diagram] No cached XML, fetching from DrawIO..."
);
currentXml = await onFetchChart(false);
"[edit_diagram] No cached XML, fetching from DrawIO...",
)
currentXml = await onFetchChart(false)
console.log(
"[edit_diagram] Got XML from export, length:",
currentXml.length
);
currentXml.length,
)
}
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
const { replaceXMLParts } = await import("@/lib/utils")
const editedXml = replaceXMLParts(currentXml, edits)
onDisplayChart(editedXml);
onDisplayChart(editedXml)
addToolResult({
tool: "edit_diagram",
toolCallId: toolCall.toolCallId,
output: `Successfully applied ${edits.length} edit(s) to the diagram.`,
});
console.log("[edit_diagram] Success");
})
console.log("[edit_diagram] Success")
} catch (error) {
console.error("[edit_diagram] Failed:", error);
console.error("[edit_diagram] Failed:", error)
const errorMessage =
error instanceof Error
? error.message
: String(error);
: String(error)
addToolResult({
tool: "edit_diagram",
@@ -186,14 +187,14 @@ ${currentXml || "No XML available"}
\`\`\`
Please retry with an adjusted search pattern or use display_diagram if retries are exhausted.`,
});
})
}
}
},
onError: (error) => {
// Silence access code error in console since it's handled by UI
if (!error.message.includes("Invalid or missing access code")) {
console.error("Chat error:", error);
console.error("Chat error:", error)
}
// Add system message for error so it can be cleared
@@ -203,63 +204,63 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
role: "system" as const,
content: error.message,
parts: [{ type: "text" as const, text: error.message }],
};
return [...currentMessages, errorMessage];
});
}
return [...currentMessages, errorMessage]
})
if (error.message.includes("Invalid or missing access code")) {
// Show settings button and open dialog to help user fix it
setAccessCodeRequired(true);
setShowSettingsDialog(true);
setAccessCodeRequired(true)
setShowSettingsDialog(true)
}
},
});
})
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages]);
}, [messages])
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const isProcessing = status === "streaming" || status === "submitted";
e.preventDefault()
const isProcessing = status === "streaming" || status === "submitted"
if (input.trim() && !isProcessing) {
try {
let chartXml = await onFetchChart();
chartXml = formatXML(chartXml);
let chartXml = await onFetchChart()
chartXml = formatXML(chartXml)
// Update ref directly to avoid race condition with React's async state update
// This ensures edit_diagram has the correct XML before AI responds
chartXMLRef.current = chartXml;
chartXMLRef.current = chartXml
const parts: any[] = [{ type: "text", text: input }];
const parts: any[] = [{ type: "text", text: input }]
if (files.length > 0) {
for (const file of files) {
const reader = new FileReader();
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () =>
resolve(reader.result as string);
reader.readAsDataURL(file);
});
resolve(reader.result as string)
reader.readAsDataURL(file)
})
parts.push({
type: "file",
url: dataUrl,
mediaType: file.type,
});
})
}
}
// Save XML snapshot for this message (will be at index = current messages.length)
const messageIndex = messages.length;
xmlSnapshotsRef.current.set(messageIndex, chartXml);
const messageIndex = messages.length
xmlSnapshotsRef.current.set(messageIndex, chartXml)
const accessCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
sendMessage(
{ parts },
{
@@ -270,78 +271,78 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
headers: {
"x-access-code": accessCode,
},
}
);
},
)
setInput("");
setFiles([]);
setInput("")
setFiles([])
} catch (error) {
console.error("Error fetching chart data:", error);
console.error("Error fetching chart data:", error)
}
}
};
}
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setInput(e.target.value);
};
setInput(e.target.value)
}
const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles);
};
setFiles(newFiles)
}
const handleRegenerate = async (messageIndex: number) => {
const isProcessing = status === "streaming" || status === "submitted";
if (isProcessing) return;
const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return
// Find the user message before this assistant message
let userMessageIndex = messageIndex - 1;
let userMessageIndex = messageIndex - 1
while (
userMessageIndex >= 0 &&
messages[userMessageIndex].role !== "user"
) {
userMessageIndex--;
userMessageIndex--
}
if (userMessageIndex < 0) return;
if (userMessageIndex < 0) return
const userMessage = messages[userMessageIndex];
const userParts = userMessage.parts;
const userMessage = messages[userMessageIndex]
const userParts = userMessage.parts
// Get the text from the user message
const textPart = userParts?.find((p: any) => p.type === "text");
if (!textPart) return;
const textPart = userParts?.find((p: any) => p.type === "text")
if (!textPart) return
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex);
const savedXml = xmlSnapshotsRef.current.get(userMessageIndex)
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
userMessageIndex
);
return;
userMessageIndex,
)
return
}
// Restore the diagram to the saved state
onDisplayChart(savedXml);
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml;
chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > userMessageIndex) {
xmlSnapshotsRef.current.delete(key);
xmlSnapshotsRef.current.delete(key)
}
}
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, userMessageIndex);
const newMessages = messages.slice(0, userMessageIndex)
flushSync(() => {
setMessages(newMessages);
});
setMessages(newMessages)
})
// Now send the message after state is guaranteed to be updated
sendMessage(
@@ -351,54 +352,54 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml,
sessionId,
},
}
);
};
},
)
}
const handleEditMessage = async (messageIndex: number, newText: string) => {
const isProcessing = status === "streaming" || status === "submitted";
if (isProcessing) return;
const isProcessing = status === "streaming" || status === "submitted"
if (isProcessing) return
const message = messages[messageIndex];
if (!message || message.role !== "user") return;
const message = messages[messageIndex]
if (!message || message.role !== "user") return
// Get the saved XML snapshot for this user message
const savedXml = xmlSnapshotsRef.current.get(messageIndex);
const savedXml = xmlSnapshotsRef.current.get(messageIndex)
if (!savedXml) {
console.error(
"No saved XML snapshot for message index:",
messageIndex
);
return;
messageIndex,
)
return
}
// Restore the diagram to the saved state
onDisplayChart(savedXml);
onDisplayChart(savedXml)
// Update ref directly to ensure edit_diagram has the correct XML
chartXMLRef.current = savedXml;
chartXMLRef.current = savedXml
// Clean up snapshots for messages after the user message (they will be removed)
for (const key of xmlSnapshotsRef.current.keys()) {
if (key > messageIndex) {
xmlSnapshotsRef.current.delete(key);
xmlSnapshotsRef.current.delete(key)
}
}
// Create new parts with updated text
const newParts = message.parts?.map((part: any) => {
if (part.type === "text") {
return { ...part, text: newText };
return { ...part, text: newText }
}
return part;
}) || [{ type: "text", text: newText }];
return part
}) || [{ type: "text", text: newText }]
// Remove the user message AND assistant message onwards (sendMessage will re-add the user message)
// Use flushSync to ensure state update is processed synchronously before sending
const newMessages = messages.slice(0, messageIndex);
const newMessages = messages.slice(0, messageIndex)
flushSync(() => {
setMessages(newMessages);
});
setMessages(newMessages)
})
// Now send the edited message after state is guaranteed to be updated
sendMessage(
@@ -408,9 +409,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
xml: savedXml,
sessionId,
},
}
);
};
},
)
}
// Collapsed view (desktop only)
if (!isVisible && !isMobile) {
@@ -435,7 +436,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
AI Chat
</div>
</div>
);
)
}
// Full view
@@ -447,7 +448,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
style={{ position: "absolute" }}
/>
{/* Header */}
<header className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}>
<header
className={`${isMobile ? "px-3 py-2" : "px-5 py-4"} border-b border-border/50`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
@@ -458,7 +461,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
height={isMobile ? 24 : 28}
className="rounded"
/>
<h1 className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}>
<h1
className={`${isMobile ? "text-sm" : "text-base"} font-semibold tracking-tight whitespace-nowrap`}
>
Next AI Drawio
</h1>
</div>
@@ -488,7 +493,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`} />
<FaGithub
className={`${isMobile ? "w-4 h-4" : "w-5 h-5"}`}
/>
</a>
{accessCodeRequired && (
<ButtonWithTooltip
@@ -498,7 +505,9 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
>
<Settings className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`} />
<Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
/>
</ButtonWithTooltip>
)}
{!isMobile && (
@@ -529,21 +538,23 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
</main>
{/* Input */}
<footer className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}>
<footer
className={`${isMobile ? "p-2" : "p-4"} border-t border-border/50 bg-card/50`}
>
<ChatInput
input={input}
status={status}
onSubmit={onFormSubmit}
onChange={handleInputChange}
onClearChat={() => {
setMessages([]);
clearDiagram();
setMessages([])
clearDiagram()
setSessionId(
`session-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 9)}`
);
xmlSnapshotsRef.current.clear();
.slice(2, 9)}`,
)
xmlSnapshotsRef.current.clear()
}}
files={files}
onFileChange={handleFileChange}
@@ -561,5 +572,5 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
onOpenChange={setShowSettingsDialog}
/>
</div>
);
)
}

View File

@@ -1,22 +1,29 @@
"use client";
"use client"
import { Highlight, themes } from "prism-react-renderer";
import { Highlight, themes } from "prism-react-renderer"
interface CodeBlockProps {
code: string;
language?: "xml" | "json";
code: string
language?: "xml" | "json"
}
export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
return (
<div className="overflow-hidden w-full">
<Highlight theme={themes.github} code={code} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
{({
className,
style,
tokens,
getLineProps,
getTokenProps,
}) => (
<pre
className="text-[11px] leading-relaxed overflow-x-auto overflow-y-auto max-h-48 scrollbar-thin break-all"
style={{
...style,
fontFamily: "var(--font-mono), ui-monospace, monospace",
fontFamily:
"var(--font-mono), ui-monospace, monospace",
backgroundColor: "transparent",
margin: 0,
padding: 0,
@@ -25,9 +32,16 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
}}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
<div
key={i}
{...getLineProps({ line })}
style={{ wordBreak: "break-all" }}
>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
<span
key={key}
{...getTokenProps({ token })}
/>
))}
</div>
))}
@@ -35,5 +49,5 @@ export function CodeBlock({ code, language = "xml" }: CodeBlockProps) {
)}
</Highlight>
</div>
);
)
}

View File

@@ -1,19 +1,19 @@
"use client";
"use client"
import React from "react";
import type React from "react"
interface ErrorToastProps {
message: React.ReactNode;
onDismiss: () => void;
message: React.ReactNode
onDismiss: () => void
}
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault();
onDismiss();
e.preventDefault()
onDismiss()
}
};
}
return (
<div
@@ -25,7 +25,12 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<svg
className="w-4 h-4 text-destructive"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
@@ -35,5 +40,5 @@ export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
</div>
<span className="text-sm text-foreground">{message}</span>
</div>
);
)
}

View File

@@ -1,40 +1,44 @@
"use client";
"use client"
import React, { useEffect, useState } from "react";
import Image from "next/image";
import { X } from "lucide-react";
import { X } from "lucide-react"
import Image from "next/image"
import React, { useEffect, useState } from "react"
interface FilePreviewListProps {
files: File[];
onRemoveFile: (fileToRemove: File) => void;
files: File[]
onRemoveFile: (fileToRemove: File) => void
}
export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState<string | null>(null)
// Cleanup object URLs on unmount
useEffect(() => {
const objectUrls = files
.filter((file) => file.type.startsWith("image/"))
.map((file) => URL.createObjectURL(file));
.map((file) => URL.createObjectURL(file))
return () => {
objectUrls.forEach(URL.revokeObjectURL);
};
}, [files]);
objectUrls.forEach(URL.revokeObjectURL)
}
}, [files])
if (files.length === 0) return null;
if (files.length === 0) return null
return (
<>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => {
const imageUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
const imageUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: null
return (
<div key={file.name + index} className="relative group">
<div
className="w-20 h-20 border rounded-md overflow-hidden bg-muted cursor-pointer"
onClick={() => imageUrl && setSelectedImage(imageUrl)}
onClick={() =>
imageUrl && setSelectedImage(imageUrl)
}
>
{file.type.startsWith("image/") ? (
<Image
@@ -59,7 +63,7 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
<X className="h-3 w-3" />
</button>
</div>
);
)
})}
</div>
@@ -89,5 +93,5 @@ export function FilePreviewList({ files, onRemoveFile }: FilePreviewListProps) {
</div>
)}
</>
);
)
}

View File

@@ -1,6 +1,8 @@
"use client";
"use client"
import { useState } from "react";
import Image from "next/image"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
@@ -8,34 +10,32 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import { useDiagram } from "@/contexts/diagram-context";
} from "@/components/ui/dialog"
import { useDiagram } from "@/contexts/diagram-context"
interface HistoryDialogProps {
showHistory: boolean;
onToggleHistory: (show: boolean) => void;
showHistory: boolean
onToggleHistory: (show: boolean) => void
}
export function HistoryDialog({
showHistory,
onToggleHistory,
}: HistoryDialogProps) {
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const { loadDiagram: onDisplayChart, diagramHistory } = useDiagram()
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const handleClose = () => {
setSelectedIndex(null);
onToggleHistory(false);
};
setSelectedIndex(null)
onToggleHistory(false)
}
const handleConfirmRestore = () => {
if (selectedIndex !== null) {
onDisplayChart(diagramHistory[selectedIndex].xml);
handleClose();
onDisplayChart(diagramHistory[selectedIndex].xml)
handleClose()
}
};
}
return (
<Dialog open={showHistory} onOpenChange={onToggleHistory}>
@@ -100,15 +100,12 @@ export function HistoryDialog({
</Button>
</>
) : (
<Button
variant="outline"
onClick={handleClose}
>
<Button variant="outline" onClick={handleClose}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}

View File

@@ -1,6 +1,6 @@
"use client";
"use client"
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog"
interface ResetWarningModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onClear: () => void;
open: boolean
onOpenChange: (open: boolean) => void
onClear: () => void
}
export function ResetWarningModal({
@@ -44,5 +44,5 @@ export function ResetWarningModal({
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}

View File

@@ -1,36 +1,40 @@
"use client";
"use client"
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from "@/components/ui/select"
export type ExportFormat = "drawio" | "png" | "svg";
export type ExportFormat = "drawio" | "png" | "svg"
const FORMAT_OPTIONS: { value: ExportFormat; label: string; extension: string }[] = [
const FORMAT_OPTIONS: {
value: ExportFormat
label: string
extension: string
}[] = [
{ value: "drawio", label: "Draw.io XML", extension: ".drawio" },
{ value: "png", label: "PNG Image", extension: ".png" },
{ value: "svg", label: "SVG Image", extension: ".svg" },
];
]
interface SaveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (filename: string, format: ExportFormat) => void;
defaultFilename: string;
open: boolean
onOpenChange: (open: boolean) => void
onSave: (filename: string, format: ExportFormat) => void
defaultFilename: string
}
export function SaveDialog({
@@ -39,29 +43,29 @@ export function SaveDialog({
onSave,
defaultFilename,
}: SaveDialogProps) {
const [filename, setFilename] = useState(defaultFilename);
const [format, setFormat] = useState<ExportFormat>("drawio");
const [filename, setFilename] = useState(defaultFilename)
const [format, setFormat] = useState<ExportFormat>("drawio")
useEffect(() => {
if (open) {
setFilename(defaultFilename);
setFilename(defaultFilename)
}
}, [open, defaultFilename]);
}, [open, defaultFilename])
const handleSave = () => {
const finalFilename = filename.trim() || defaultFilename;
onSave(finalFilename, format);
onOpenChange(false);
};
const finalFilename = filename.trim() || defaultFilename
onSave(finalFilename, format)
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
e.preventDefault()
handleSave()
}
};
}
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format);
const currentFormat = FORMAT_OPTIONS.find((f) => f.value === format)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,13 +76,19 @@ export function SaveDialog({
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Format</label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
<Select
value={format}
onValueChange={(v) => setFormat(v as ExportFormat)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FORMAT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
))}
@@ -104,12 +114,15 @@ export function SaveDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}

View File

@@ -1,48 +1,46 @@
"use client";
"use client"
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog";
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
open: boolean
onOpenChange: (open: boolean) => void
}
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code";
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
export function SettingsDialog({
open,
onOpenChange,
}: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("");
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const [accessCode, setAccessCode] = useState("")
useEffect(() => {
if (open) {
const storedCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || "";
setAccessCode(storedCode);
const storedCode =
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
setAccessCode(storedCode)
}
}, [open]);
}, [open])
const handleSave = () => {
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim());
onOpenChange(false);
};
localStorage.setItem(STORAGE_ACCESS_CODE_KEY, accessCode.trim())
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
e.preventDefault()
handleSave()
}
};
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -72,12 +70,15 @@ export function SettingsDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
)
}