feat: refresh UI with new typography and edit diff display (#63)

- Switch from Geist to Plus Jakarta Sans (body) and JetBrains Mono (code)
- Add visual diff display for edit_diagram tool showing search/replace pairs
- Update color palette to clean modern OKLCH-based scheme
- Improve chat message display with better styling and animations
- Add syntax-highlighted code blocks for XML/JSON output
- Improve scrollbar and shadow utilities
This commit is contained in:
Dayuan Jiang
2025-12-03 21:49:34 +09:00
committed by GitHub
parent 5021076864
commit 110cccb09c
10 changed files with 746 additions and 450 deletions

View File

@@ -7,8 +7,8 @@
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -45,72 +45,102 @@
} }
:root { :root {
--radius: 0.625rem; --radius: 0.75rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); /* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.23 0.02 260);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.23 0.02 260);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); /* Dark primary - slightly lighter */
--secondary: oklch(0.97 0 0); --primary: oklch(0.35 0.01 260);
--secondary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.99 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); /* Warm gray secondary */
--accent: oklch(0.97 0 0); --secondary: oklch(0.96 0.005 260);
--accent-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.35 0.02 260);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); /* Light muted tones */
--input: oklch(0.922 0 0); --muted: oklch(0.965 0.005 260);
--ring: oklch(0.708 0 0); --muted-foreground: oklch(0.50 0.02 260);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); /* Soft lavender accent */
--chart-3: oklch(0.398 0.07 227.392); --accent: oklch(0.94 0.03 280);
--chart-4: oklch(0.828 0.189 84.429); --accent-foreground: oklch(0.35 0.08 270);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); /* Coral destructive */
--sidebar-foreground: oklch(0.145 0 0); --destructive: oklch(0.60 0.20 25);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); /* Subtle borders */
--sidebar-accent: oklch(0.97 0 0); --border: oklch(0.92 0.01 260);
--sidebar-accent-foreground: oklch(0.205 0 0); --input: oklch(0.94 0.01 260);
--sidebar-border: oklch(0.922 0 0); --ring: oklch(0.25 0.01 260);
--sidebar-ring: oklch(0.708 0 0);
/* Chart colors - harmonious palette */
--chart-1: oklch(0.55 0.18 265);
--chart-2: oklch(0.65 0.15 170);
--chart-3: oklch(0.70 0.18 45);
--chart-4: oklch(0.60 0.20 330);
--chart-5: oklch(0.50 0.15 200);
/* Sidebar */
--sidebar: oklch(0.99 0.002 260);
--sidebar-foreground: oklch(0.23 0.02 260);
--sidebar-primary: oklch(0.55 0.18 265);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.96 0.02 270);
--sidebar-accent-foreground: oklch(0.35 0.05 265);
--sidebar-border: oklch(0.93 0.01 260);
--sidebar-ring: oklch(0.55 0.18 265);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.15 0.015 260);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.95 0.01 260);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card: oklch(0.20 0.015 260);
--popover: oklch(0.205 0 0); --card-foreground: oklch(0.95 0.01 260);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --popover: oklch(0.20 0.015 260);
--primary-foreground: oklch(0.205 0 0); --popover-foreground: oklch(0.95 0.01 260);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --primary: oklch(0.70 0.16 265);
--muted: oklch(0.269 0 0); --primary-foreground: oklch(0.15 0.02 260);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --secondary: oklch(0.25 0.015 260);
--accent-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.90 0.01 260);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --muted: oklch(0.25 0.015 260);
--input: oklch(1 0 0 / 15%); --muted-foreground: oklch(0.65 0.02 260);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376); --accent: oklch(0.30 0.04 280);
--chart-2: oklch(0.696 0.17 162.48); --accent-foreground: oklch(0.90 0.03 270);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --destructive: oklch(0.65 0.22 25);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --border: oklch(0.28 0.015 260);
--sidebar-foreground: oklch(0.985 0 0); --input: oklch(0.25 0.015 260);
--sidebar-primary: oklch(0.488 0.243 264.376); --ring: oklch(0.70 0.16 265);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --chart-1: oklch(0.70 0.16 265);
--sidebar-accent-foreground: oklch(0.985 0 0); --chart-2: oklch(0.70 0.13 170);
--sidebar-border: oklch(1 0 0 / 10%); --chart-3: oklch(0.75 0.16 45);
--sidebar-ring: oklch(0.556 0 0); --chart-4: oklch(0.70 0.18 330);
--chart-5: oklch(0.60 0.13 200);
--sidebar: oklch(0.18 0.015 260);
--sidebar-foreground: oklch(0.95 0.01 260);
--sidebar-primary: oklch(0.70 0.16 265);
--sidebar-primary-foreground: oklch(0.15 0.02 260);
--sidebar-accent: oklch(0.25 0.03 270);
--sidebar-accent-foreground: oklch(0.90 0.02 265);
--sidebar-border: oklch(0.28 0.015 260);
--sidebar-ring: oklch(0.70 0.16 265);
} }
@layer base { @layer base {
@@ -118,6 +148,101 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
} }
} }
/* Custom scrollbar */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: oklch(0.85 0.01 260) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: oklch(0.85 0.01 260);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.75 0.01 260);
}
}
/* Smooth page transitions */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out forwards;
}
/* Message bubble animations */
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-message-in {
animation: messageIn 0.25s ease-out forwards;
}
/* Subtle floating shadow for cards */
.shadow-soft {
box-shadow:
0 1px 2px oklch(0.23 0.02 260 / 0.04),
0 4px 12px oklch(0.23 0.02 260 / 0.06),
0 8px 24px oklch(0.23 0.02 260 / 0.04);
}
.shadow-soft-lg {
box-shadow:
0 2px 4px oklch(0.23 0.02 260 / 0.04),
0 8px 20px oklch(0.23 0.02 260 / 0.08),
0 16px 40px oklch(0.23 0.02 260 / 0.06);
}
/* Gradient text utility */
.text-gradient-primary {
background: linear-gradient(135deg, oklch(0.55 0.18 265), oklch(0.60 0.20 290));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

View File

@@ -1,19 +1,21 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import { GoogleAnalytics } from "@next/third-parties/google"; import { GoogleAnalytics } from "@next/third-parties/google";
import { DiagramProvider } from "@/contexts/diagram-context"; import { DiagramProvider } from "@/contexts/diagram-context";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-geist-sans", variable: "--font-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"],
}); });
const geistMono = Geist_Mono({ const jetbrainsMono = JetBrains_Mono({
variable: "--font-geist-mono", variable: "--font-mono",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -91,7 +93,7 @@ export default function RootLayout({
/> />
</head> </head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
> >
<DiagramProvider>{children}</DiagramProvider> <DiagramProvider>{children}</DiagramProvider>

View File

@@ -3,6 +3,7 @@ import React, { useState, useEffect } from "react";
import { DrawIoEmbed } from "react-drawio"; import { DrawIoEmbed } from "react-drawio";
import ChatPanel from "@/components/chat-panel"; import ChatPanel from "@/components/chat-panel";
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context";
import { Monitor } from "lucide-react";
export default function Home() { export default function Home() {
const { drawioRef, handleDiagramExport } = useDiagram(); const { drawioRef, handleDiagramExport } = useDiagram();
@@ -14,17 +15,11 @@ export default function Home() {
setIsMobile(window.innerWidth < 768); setIsMobile(window.innerWidth < 768);
}; };
// Check on mount
checkMobile(); checkMobile();
// Add event listener for resize
window.addEventListener("resize", checkMobile); window.addEventListener("resize", checkMobile);
// Cleanup
return () => window.removeEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile);
}, []); }, []);
// Add keyboard shortcut for toggling chat panel (Ctrl+B)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'b') { if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
@@ -34,42 +29,56 @@ export default function Home() {
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []); }, []);
return ( return (
<div className="flex h-screen bg-gray-100 relative"> <div className="flex h-screen bg-background relative overflow-hidden">
{/* Mobile warning overlay - keeps components mounted */} {/* Mobile warning overlay */}
{isMobile && ( {isMobile && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-gray-100"> <div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
<div className="text-center p-8"> <div className="text-center p-8 max-w-sm mx-auto animate-fade-in">
<h1 className="text-2xl font-semibold text-gray-800"> <div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
Please open this application on a desktop or laptop <Monitor className="w-8 h-8 text-primary" />
</div>
<h1 className="text-xl font-semibold text-foreground mb-3">
Desktop Required
</h1> </h1>
<p className="text-sm text-muted-foreground leading-relaxed">
This application works best on desktop or laptop devices. Please open it on a larger screen for the full experience.
</p>
</div> </div>
</div> </div>
)} )}
<div className={`${isChatVisible ? 'w-2/3' : 'w-full'} p-1 h-full relative transition-all duration-300 ease-in-out`}> {/* Draw.io Canvas */}
<DrawIoEmbed <div
ref={drawioRef} className={`${isChatVisible ? 'w-2/3' : 'w-full'} h-full relative transition-all duration-300 ease-out`}
onExport={handleDiagramExport} >
urlParameters={{ <div className="absolute inset-2 rounded-xl overflow-hidden shadow-soft-lg border border-border/30 bg-white">
spin: true, <DrawIoEmbed
libraries: false, ref={drawioRef}
saveAndExit: false, onExport={handleDiagramExport}
noExitBtn: true, urlParameters={{
}} spin: true,
/> libraries: false,
saveAndExit: false,
noExitBtn: true,
}}
/>
</div>
</div> </div>
<div className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full p-1 transition-all duration-300 ease-in-out`}>
<ChatPanel {/* Chat Panel */}
isVisible={isChatVisible} <div
onToggleVisibility={() => setIsChatVisible(!isChatVisible)} className={`${isChatVisible ? 'w-1/3' : 'w-12'} h-full transition-all duration-300 ease-out`}
/> >
<div className="h-full py-2 pr-2">
<ChatPanel
isVisible={isChatVisible}
onToggleVisibility={() => setIsChatVisible(!isChatVisible)}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,3 +1,37 @@
"use client";
import { Zap, Cloud, GitBranch, Palette } from "lucide-react";
interface ExampleCardProps {
icon: React.ReactNode;
title: string;
description: string;
onClick: () => void;
}
function ExampleCard({ icon, title, description, onClick }: ExampleCardProps) {
return (
<button
onClick={onClick}
className="group w-full text-left p-4 rounded-xl border border-border/60 bg-card hover:bg-accent/50 hover:border-primary/30 transition-all duration-200 hover:shadow-sm"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 group-hover:bg-primary/15 transition-colors">
{icon}
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{description}
</p>
</div>
</div>
</button>
);
}
export default function ExamplePanel({ export default function ExamplePanel({
setInput, setInput,
setFiles, setFiles,
@@ -5,80 +39,85 @@ export default function ExamplePanel({
setInput: (input: string) => void; setInput: (input: string) => void;
setFiles: (files: File[]) => void; setFiles: (files: File[]) => void;
}) { }) {
// New handler for the "Replicate this flowchart" button
const handleReplicateFlowchart = async () => { const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart."); setInput("Replicate this flowchart.");
try { try {
// Fetch the example image
const response = await fetch("/example.png"); const response = await fetch("/example.png");
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], "example.png", { type: "image/png" }); const file = new File([blob], "example.png", { type: "image/png" });
// Set the file to the files state
setFiles([file]); setFiles([file]);
} catch (error) { } catch (error) {
console.error("Error loading example image:", error); console.error("Error loading example image:", error);
} }
}; };
// Handler for the "Replicate this in aws style" button
const handleReplicateArchitecture = async () => { const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style"); setInput("Replicate this in aws style");
try { try {
// Fetch the architecture image
const response = await fetch("/architecture.png"); const response = await fetch("/architecture.png");
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], "architecture.png", { const file = new File([blob], "architecture.png", {
type: "image/png", type: "image/png",
}); });
// Set the file to the files state
setFiles([file]); setFiles([file]);
} catch (error) { } catch (error) {
console.error("Error loading architecture image:", error); console.error("Error loading architecture image:", error);
} }
}; };
return ( return (
<div className="px-4 py-2 border-t border-b border-gray-100"> <div className="py-6 px-2 animate-fade-in">
<p className="text-sm text-gray-500 mb-2"> {/* Welcome section */}
{" "} <div className="text-center mb-6">
Start a conversation to generate or modify diagrams. <h2 className="text-lg font-semibold text-foreground mb-2">
</p> Create diagrams with AI
<p className="text-sm text-gray-500 mb-2"> </h2>
{" "} <p className="text-sm text-muted-foreground max-w-xs mx-auto">
You can also upload images to use as references. Describe what you want to create or upload an image to replicate
</p> </p>
<p className="text-sm text-gray-500 mb-2"> </div>
Try these examples{" "}
<span className="text-xs text-gray-400">(cached for instant response)</span>: {/* Examples grid */}
</p> <div className="space-y-3">
<div className="flex flex-wrap gap-5"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<button Quick Examples
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded" </p>
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
> <div className="grid gap-2">
Draw diagram with Animated Connectors <ExampleCard
</button> icon={<Zap className="w-4 h-4 text-primary" />}
<button title="Animated Diagram"
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded" description="Draw a transformer architecture with animated connectors"
onClick={handleReplicateArchitecture} onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
> />
Create AWS architecture
</button> <ExampleCard
<button icon={<Cloud className="w-4 h-4 text-primary" />}
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded" title="AWS Architecture"
onClick={handleReplicateFlowchart} description="Create a cloud architecture diagram with AWS icons"
> onClick={handleReplicateArchitecture}
Replicate flowchart />
</button>
<button <ExampleCard
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded" icon={<GitBranch className="w-4 h-4 text-primary" />}
onClick={() => setInput("Draw a cat for me")} title="Replicate Flowchart"
> description="Upload and replicate an existing flowchart"
Draw a cat onClick={handleReplicateFlowchart}
</button> />
<ExampleCard
icon={<Palette className="w-4 h-4 text-primary" />}
title="Creative Drawing"
description="Draw something fun and creative"
onClick={() => setInput("Draw a cat for me")}
/>
</div>
<p className="text-[11px] text-muted-foreground/60 text-center mt-4">
Examples are cached for instant response
</p>
</div> </div>
</div> </div>
); );

View File

@@ -12,6 +12,7 @@ import {
Image as ImageIcon, Image as ImageIcon,
History, History,
Download, Download,
Paperclip,
} from "lucide-react"; } from "lucide-react";
import { ButtonWithTooltip } from "@/components/button-with-tooltip"; import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list"; import { FilePreviewList } from "./file-preview-list";
@@ -48,13 +49,12 @@ export function ChatInput({
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false);
// Debug: Log status changes
const isDisabled = status === "streaming" || status === "submitted"; const isDisabled = status === "streaming" || status === "submitted";
useEffect(() => { useEffect(() => {
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled); console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]); }, [status, isDisabled]);
// Auto-resize textarea based on content
const adjustTextareaHeight = useCallback(() => { const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (textarea) { if (textarea) {
@@ -67,7 +67,6 @@ export function ChatInput({
adjustTextareaHeight(); adjustTextareaHeight();
}, [input, adjustTextareaHeight]); }, [input, adjustTextareaHeight]);
// Handle keyboard shortcuts and paste events
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@@ -78,7 +77,6 @@ export function ChatInput({
} }
}; };
// Handle clipboard paste
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return; if (isDisabled) return;
@@ -92,7 +90,6 @@ export function ChatInput({
imageItems.map(async (item) => { imageItems.map(async (item) => {
const file = item.getAsFile(); const file = item.getAsFile();
if (!file) return null; if (!file) return null;
// Create a new file with a unique name
return new File( return new File(
[file], [file],
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`, `pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
@@ -112,13 +109,11 @@ export function ChatInput({
} }
}; };
// Handle file changes
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []); const newFiles = Array.from(e.target.files || []);
onFileChange([...files, ...newFiles]); onFileChange([...files, ...newFiles]);
}; };
// Remove individual file
const handleRemoveFile = (fileToRemove: File) => { const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove)); onFileChange(files.filter((file) => file !== fileToRemove));
if (fileInputRef.current) { if (fileInputRef.current) {
@@ -126,12 +121,10 @@ export function ChatInput({
} }
}; };
// Trigger file input click
const triggerFileInput = () => { const triggerFileInput = () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
// Handle drag events
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => { const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -153,7 +146,6 @@ export function ChatInput({
const droppedFiles = e.dataTransfer.files; const droppedFiles = e.dataTransfer.files;
// Only process image files
const imageFiles = Array.from(droppedFiles).filter((file) => const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/") file.type.startsWith("image/")
); );
@@ -163,141 +155,148 @@ export function ChatInput({
} }
}; };
// Handle clearing conversation and diagram
const handleClear = () => { const handleClear = () => {
onClearChat(); onClearChat();
setShowClearDialog(false); setShowClearDialog(false);
}; };
return ( return (
<form <form
onSubmit={onSubmit} onSubmit={onSubmit}
className={`w-full space-y-2 ${ className={`w-full transition-all duration-200 ${
isDragging isDragging
? "border-2 border-dashed border-primary p-4 rounded-lg bg-muted/20" ? "ring-2 ring-primary ring-offset-2 rounded-2xl"
: "" : ""
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
> >
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} /> {/* File previews */}
{files.length > 0 && (
<Textarea <div className="mb-3">
ref={textareaRef} <FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
value={input}
onChange={onChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe what changes you want to make to the diagram
or upload(paste) an image to replicate a diagram.
(Press Cmd/Ctrl + Enter to send)"
disabled={isDisabled}
aria-label="Chat input"
className="min-h-[80px] resize-none transition-all duration-200 px-1 py-0"
/>
<div className="flex items-center gap-2">
<div className="mr-auto">
<ButtonWithTooltip
type="button"
variant="ghost"
size="icon"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear current conversation and diagram"
>
<Trash2 className="h-4 w-4" />
</ButtonWithTooltip>
{/* Warning Modal */}
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
</div> </div>
<div className="flex gap-2"> )}
{/* History Button */}
<ButtonWithTooltip
type="button"
variant="outline"
size="icon"
onClick={() => onToggleHistory(true)}
disabled={
isDisabled ||
diagramHistory.length === 0
}
title="Diagram History"
tooltipContent="View diagram history"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
{/* Save Diagram Button */} {/* Input container */}
<ButtonWithTooltip <div className="relative rounded-2xl border border-border bg-background shadow-sm focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/50 transition-all duration-200">
type="button" <Textarea
variant="outline" ref={textareaRef}
size="icon" value={input}
onClick={() => setShowSaveDialog(true)} onChange={onChange}
disabled={isDisabled} onKeyDown={handleKeyDown}
tooltipContent="Save diagram to local file" onPaste={handlePaste}
> placeholder="Describe your diagram or paste an image..."
<Download className="h-4 w-4" /> disabled={isDisabled}
</ButtonWithTooltip> aria-label="Chat input"
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground/60"
/>
<SaveDialog {/* Action bar */}
open={showSaveDialog} <div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
onOpenChange={setShowSaveDialog} {/* Left actions */}
onSave={saveDiagramToFile} <div className="flex items-center gap-1">
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`} <ButtonWithTooltip
/> type="button"
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
tooltipContent="Clear conversation"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="h-4 w-4" />
</ButtonWithTooltip>
<Button <ResetWarningModal
type="button" open={showClearDialog}
variant="outline" onOpenChange={setShowClearDialog}
size="icon" onClear={handleClear}
onClick={triggerFileInput} />
disabled={isDisabled}
title="Upload image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<input <HistoryDialog
type="file" showHistory={showHistory}
ref={fileInputRef} onToggleHistory={onToggleHistory}
className="hidden" />
onChange={handleFileChange} </div>
accept="image/*"
multiple {/* Right actions */}
disabled={isDisabled} <div className="flex items-center gap-1">
/> <ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => onToggleHistory(true)}
disabled={isDisabled || diagramHistory.length === 0}
tooltipContent="Diagram history"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<History className="h-4 w-4" />
</ButtonWithTooltip>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent="Save diagram"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={saveDiagramToFile}
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
/>
<ButtonWithTooltip
type="button"
variant="ghost"
size="sm"
onClick={triggerFileInput}
disabled={isDisabled}
tooltipContent="Upload image"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ImageIcon className="h-4 w-4" />
</ButtonWithTooltip>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
disabled={isDisabled}
/>
<div className="w-px h-5 bg-border mx-1" />
<Button
type="submit"
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={isDisabled ? "Sending..." : "Send message"}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-1.5" />
Send
</>
)}
</Button>
</div>
</div> </div>
<Button
type="submit"
disabled={isDisabled || !input.trim()}
className="transition-opacity"
aria-label={
isDisabled
? "Sending message..."
: "Send message"
}
>
{isDisabled ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Send
</Button>
</div> </div>
</form> </form>
); );
} }

View File

@@ -6,7 +6,51 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import ExamplePanel from "./chat-example-panel"; import ExamplePanel from "./chat-example-panel";
import { UIMessage } from "ai"; import { UIMessage } from "ai";
import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils"; import { convertToLegalXml, replaceNodes, validateMxCellStructure } from "@/lib/utils";
import { Copy, Check, X } from "lucide-react"; import { Copy, Check, X, ChevronDown, ChevronUp, Cpu, Minus, Plus } from "lucide-react";
import { CodeBlock } from "./code-block";
interface EditPair {
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 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}
</span>
</div>
<div className="divide-y divide-border/30">
{/* Search (old) */}
<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>
</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}
</pre>
</div>
{/* Replace (new) */}
<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>
</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}
</pre>
</div>
</div>
</div>
))}
</div>
);
}
import { useDiagram } from "@/contexts/diagram-context"; import { useDiagram } from "@/contexts/diagram-context";
@@ -60,10 +104,9 @@ export function ChatMessageDisplay({
if (convertedXml !== previousXML.current) { if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml); const replacedXML = replaceNodes(chartXML, convertedXml);
// Validate before sending to draw.io to prevent "d.setId is not a function" errors
const validationError = validateMxCellStructure(replacedXML); const validationError = validateMxCellStructure(replacedXML);
if (!validationError) { if (!validationError) {
previousXML.current = convertedXml; // Only update on success previousXML.current = convertedXml;
onDisplayChart(replacedXML); onDisplayChart(replacedXML);
} else { } else {
console.error("[ChatMessageDisplay] XML validation failed:", validationError); console.error("[ChatMessageDisplay] XML validation failed:", validationError);
@@ -79,7 +122,6 @@ export function ChatMessageDisplay({
} }
}, [messages]); }, [messages]);
// Handle tool invocations and update diagram when needed
useEffect(() => { useEffect(() => {
messages.forEach((message) => { messages.forEach((message) => {
if (message.parts) { if (message.parts) {
@@ -87,7 +129,6 @@ export function ChatMessageDisplay({
if (part.type?.startsWith("tool-")) { if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part; const { toolCallId, state } = part;
// Auto-collapse args when diagrams are generated
if (state === "output-available") { if (state === "output-available") {
setExpandedTools((prev) => ({ setExpandedTools((prev) => ({
...prev, ...prev,
@@ -95,20 +136,16 @@ export function ChatMessageDisplay({
})); }));
} }
// Handle diagram updates for display_diagram tool
if ( if (
part.type === "tool-display_diagram" && part.type === "tool-display_diagram" &&
part.input?.xml part.input?.xml
) { ) {
// For streaming input, always update to show streaming
if ( if (
state === "input-streaming" || state === "input-streaming" ||
state === "input-available" state === "input-available"
) { ) {
handleDisplayChart(part.input.xml); handleDisplayChart(part.input.xml);
} } else if (
// For completed calls, only update if not processed yet
else if (
state === "output-available" && state === "output-available" &&
!processedToolCalls.current.has(toolCallId) !processedToolCalls.current.has(toolCallId)
) { ) {
@@ -135,125 +172,163 @@ export function ChatMessageDisplay({
})); }));
}; };
const getToolDisplayName = (name: string) => {
switch (name) {
case "display_diagram":
return "Generate Diagram";
case "edit_diagram":
return "Edit Diagram";
default:
return name;
}
};
return ( return (
<div <div
key={callId} key={callId}
className="p-4 my-2 text-gray-500 border border-gray-300 rounded overflow-hidden" className="my-3 rounded-xl border border-border/60 bg-muted/30 overflow-hidden"
> >
<div className="flex flex-col gap-2 min-w-0"> <div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2">
<div className="text-xs">Tool: {toolName}</div> <div className="w-6 h-6 rounded-md bg-primary/10 flex items-center justify-center">
<Cpu className="w-3.5 h-3.5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground/80">
{getToolDisplayName(toolName)}
</span>
</div>
<div className="flex items-center gap-2">
{state === "input-streaming" && (
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
)}
{state === "output-available" && (
<span className="text-xs font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">
Complete
</span>
)}
{state === "output-error" && (
<span className="text-xs font-medium text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
Error
</span>
)}
{input && Object.keys(input).length > 0 && ( {input && Object.keys(input).length > 0 && (
<button <button
onClick={toggleExpanded} onClick={toggleExpanded}
className="text-xs text-gray-500 hover:text-gray-700" className="p-1 rounded hover:bg-muted transition-colors"
> >
{isExpanded ? "Hide Args" : "Show Args"} {isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button> </button>
)} )}
</div> </div>
{input && isExpanded && ( </div>
<pre className="mt-1 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-48"> {input && isExpanded && (
{typeof input === "object" && <div className="px-4 py-3 border-t border-border/40 bg-muted/20">
Object.keys(input).length > 0 && {typeof input === "object" && input.xml ? (
`Input: ${JSON.stringify(input, null, 2)}`} <CodeBlock code={input.xml} language="xml" />
</pre> ) : typeof input === "object" && input.edits && Array.isArray(input.edits) ? (
)} <EditDiffDisplay edits={input.edits} />
<div className="mt-2 text-sm"> ) : typeof input === "object" && Object.keys(input).length > 0 ? (
{state === "input-streaming" ? ( <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : state === "output-available" ? (
<div className="text-green-600">
{output || (toolName === "display_diagram"
? "Diagram generated"
: toolName === "edit_diagram"
? "Diagram edited"
: "Tool executed")}
</div>
) : state === "output-error" ? (
<div className="text-red-600">
{output || (toolName === "display_diagram"
? "Error generating diagram"
: toolName === "edit_diagram"
? "Error editing diagram"
: "Tool error")}
</div>
) : null} ) : null}
</div> </div>
</div> )}
{output && state === "output-error" && (
<div className="px-4 py-3 border-t border-border/40 text-sm text-red-600">
{output}
</div>
)}
</div> </div>
); );
}; };
return ( return (
<ScrollArea className="h-full pr-4"> <ScrollArea className="h-full px-4 scrollbar-thin">
{messages.length === 0 ? ( {messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} /> <ExamplePanel setInput={setInput} setFiles={setFiles} />
) : ( ) : (
messages.map((message) => { <div className="py-4 space-y-4">
const userMessageText = message.role === "user" ? getMessageTextContent(message) : ""; {messages.map((message, messageIndex) => {
return ( const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
<div return (
key={message.id}
className={`mb-4 flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
{message.role === "user" && userMessageText && (
<button
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors self-center mr-1"
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
className={`px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${message.role === "user" key={message.id}
? "bg-primary text-primary-foreground" className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
: "bg-muted text-muted-foreground" style={{ animationDelay: `${messageIndex * 50}ms` }}
}`}
> >
{message.parts?.map((part: any, index: number) => { {message.role === "user" && userMessageText && (
switch (part.type) { <button
case "text": onClick={() => copyMessageToClipboard(message.id, userMessageText)}
return ( className="p-1.5 rounded-lg text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted transition-colors self-center mr-2"
<div key={index}>{part.text}</div> title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
); >
case "file": {copiedMessageId === message.id ? (
return ( <Check className="h-3.5 w-3.5 text-green-500" />
<div key={index} className="mt-2"> ) : copyFailedMessageId === message.id ? (
<Image <X className="h-3.5 w-3.5 text-red-500" />
src={part.url} ) : (
width={200} <Copy className="h-3.5 w-3.5" />
height={200} )}
alt={`Uploaded diagram or image for AI analysis`} </button>
className="rounded-md border" )}
style={{ <div className="max-w-[85%]">
objectFit: "contain", {/* Text content in bubble */}
}} {message.parts?.some((part: any) => part.type === "text" || part.type === "file") && (
/> <div
</div> className={`px-4 py-3 text-sm leading-relaxed ${
); message.role === "user"
default: ? "bg-primary text-primary-foreground rounded-2xl rounded-br-md shadow-sm"
if (part.type?.startsWith("tool-")) { : "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
return renderToolPart(part); }`}
} >
return null; {message.parts?.map((part: any, index: number) => {
} switch (part.type) {
})} case "text":
return (
<div key={index} className="whitespace-pre-wrap break-words">
{part.text}
</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 null;
})}
</div>
</div> </div>
</div> );
); })}
}) </div>
)} )}
{error && ( {error && (
<div className="text-red-500 text-sm mt-2"> <div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
Error: {error.message} <span className="font-medium">Error:</span> {error.message}
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@@ -5,14 +5,8 @@ import { useRef, useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa"; import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen } from "lucide-react"; import { PanelRightClose, PanelRightOpen } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { ChatInput } from "@/components/chat-input"; import { ChatInput } from "@/components/chat-input";
@@ -57,24 +51,11 @@ export default function ChatPanel({
), ),
]); ]);
}; };
// Add a step counter to track updates
// Add state for file attachments
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
// Add state for showing the history dialog
const [showHistory, setShowHistory] = useState(false); 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(""); const [input, setInput] = useState("");
// Remove the currentXmlRef and related useEffect
const { messages, sendMessage, addToolResult, status, error, setMessages } = const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({ useChat({
transport: new DefaultChatTransport({ transport: new DefaultChatTransport({
@@ -84,7 +65,6 @@ export default function ChatPanel({
if (toolCall.toolName === "display_diagram") { if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string }; const { xml } = toolCall.input as { xml: string };
// Validate XML structure before confirming success
const validationError = validateMxCellStructure(xml); const validationError = validateMxCellStructure(xml);
if (validationError) { if (validationError) {
@@ -107,14 +87,11 @@ export default function ChatPanel({
let currentXml = ""; let currentXml = "";
try { try {
// Fetch current chart XML
currentXml = await onFetchChart(); currentXml = await onFetchChart();
// Apply edits using the utility function
const { replaceXMLParts } = await import("@/lib/utils"); const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits); const editedXml = replaceXMLParts(currentXml, edits);
// Load the edited diagram
onDisplayChart(editedXml); onDisplayChart(editedXml);
addToolResult({ addToolResult({
@@ -130,7 +107,6 @@ export default function ChatPanel({
? error.message ? error.message
: String(error); : String(error);
// Provide detailed error with current diagram XML
addToolResult({ addToolResult({
tool: "edit_diagram", tool: "edit_diagram",
toolCallId: toolCall.toolCallId, toolCallId: toolCall.toolCallId,
@@ -150,15 +126,15 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
console.error("Chat error:", error); console.error("Chat error:", error);
}, },
}); });
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => { useEffect(() => {
if (messagesEndRef.current) { if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
} }
}, [messages]); }, [messages]);
// Debug: Log status changes
useEffect(() => { useEffect(() => {
console.log("[ChatPanel] Status changed to:", status); console.log("[ChatPanel] Status changed to:", status);
}, [status]); }, [status]);
@@ -168,16 +144,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
const isProcessing = status === "streaming" || status === "submitted"; const isProcessing = status === "streaming" || status === "submitted";
if (input.trim() && !isProcessing) { if (input.trim() && !isProcessing) {
try { try {
// Fetch chart data before sending message
let chartXml = await onFetchChart(); let chartXml = await onFetchChart();
// Format the XML to ensure consistency
chartXml = formatXML(chartXml); chartXml = formatXML(chartXml);
// Create message parts
const parts: any[] = [{ type: "text", text: input }]; const parts: any[] = [{ type: "text", text: input }];
// Add file parts if files exist
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
const reader = new FileReader(); const reader = new FileReader();
@@ -204,7 +175,6 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
} }
); );
// Clear input and files after submission
setInput(""); setInput("");
setFiles([]); setFiles([]);
} catch (error) { } catch (error) {
@@ -213,85 +183,102 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
} }
}; };
// Handle input change
const handleInputChange = ( const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => { ) => {
setInput(e.target.value); setInput(e.target.value);
}; };
// Helper function to handle file changes
const handleFileChange = (newFiles: File[]) => { const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles); setFiles(newFiles);
}; };
// Collapsed view when chat is hidden // Collapsed view
if (!isVisible) { if (!isVisible) {
return ( return (
<Card className="h-full flex flex-col rounded-none py-0 gap-0 items-center justify-start pt-4"> <div className="h-full flex flex-col items-center pt-4 bg-card border border-border/30 rounded-xl">
<ButtonWithTooltip <ButtonWithTooltip
tooltipContent="Show chat panel (Ctrl+B)" tooltipContent="Show chat panel (Ctrl+B)"
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleVisibility} onClick={onToggleVisibility}
className="hover:bg-accent transition-colors"
> >
<PanelRightOpen className="h-5 w-5" /> <PanelRightOpen className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip> </ButtonWithTooltip>
<div <div
className="text-sm text-gray-500 mt-8" className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
style={{ style={{
writingMode: "vertical-rl", writingMode: "vertical-rl",
transform: "rotate(180deg)", transform: "rotate(180deg)",
}} }}
> >
Chat AI Chat
</div> </div>
</Card> </div>
); );
} }
// Full view when chat is visible // Full view
return ( return (
<Card className="h-full flex flex-col rounded-none py-0 gap-0"> <div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
<CardHeader className="p-4 flex flex-row justify-between items-center"> {/* Header */}
<div className="flex items-center gap-3"> <header className="px-5 py-4 border-b border-border/50">
<CardTitle>Next-AI-Drawio</CardTitle> <div className="flex items-center justify-between">
<Link <div className="flex items-center gap-3">
href="/about" <div className="flex items-center gap-2">
className="text-sm text-gray-600 hover:text-gray-900 transition-colors" <Image
> src="/favicon.ico"
About alt="Next AI Drawio"
</Link> width={28}
height={28}
className="rounded"
/>
<h1 className="text-base font-semibold tracking-tight whitespace-nowrap">
Next AI Drawio
</h1>
</div>
<Link
href="/about"
className="text-sm text-muted-foreground hover:text-foreground transition-colors ml-2"
>
About
</Link>
</div>
<div className="flex items-center gap-1">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FaGithub className="w-5 h-5" />
</a>
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent"
>
<PanelRightClose className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
</div>
</div> </div>
<div className="flex items-center gap-2"> </header>
<a
href="https://github.com/DayuanJiang/next-ai-draw-io" {/* Messages */}
target="_blank" <main className="flex-1 overflow-hidden">
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<FaGithub className="w-6 h-6" />
</a>
<ButtonWithTooltip
tooltipContent="Hide chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
>
<PanelRightClose className="h-5 w-5" />
</ButtonWithTooltip>
</div>
</CardHeader>
<CardContent className="flex-grow overflow-hidden px-2">
<ChatMessageDisplay <ChatMessageDisplay
messages={messages} messages={messages}
error={error} error={error}
setInput={setInput} setInput={setInput}
setFiles={handleFileChange} setFiles={handleFileChange}
/> />
</CardContent> </main>
<CardFooter className="p-2"> {/* Input */}
<footer className="p-4 border-t border-border/50 bg-card/50">
<ChatInput <ChatInput
input={input} input={input}
status={status} status={status}
@@ -306,7 +293,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
showHistory={showHistory} showHistory={showHistory}
onToggleHistory={setShowHistory} onToggleHistory={setShowHistory}
/> />
</CardFooter> </footer>
</Card> </div>
); );
} }

39
components/code-block.tsx Normal file
View File

@@ -0,0 +1,39 @@
"use client";
import { Highlight, themes } from "prism-react-renderer";
interface CodeBlockProps {
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 }) => (
<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",
backgroundColor: "transparent",
margin: 0,
padding: 0,
wordBreak: "break-all",
whiteSpace: "pre-wrap",
}}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })} style={{ wordBreak: "break-all" }}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
</div>
);
}

20
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"next": "15.2.3", "next": "15.2.3",
"ollama-ai-provider-v2": "^1.5.4", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-drawio": "^1.0.3", "react-drawio": "^1.0.3",
@@ -2792,6 +2793,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/prismjs": {
"version": "1.26.5",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
@@ -7867,6 +7874,19 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prism-react-renderer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz",
"integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==",
"license": "MIT",
"dependencies": {
"@types/prismjs": "^1.26.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.0.0"
}
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@@ -33,6 +33,7 @@
"next": "15.2.3", "next": "15.2.3",
"ollama-ai-provider-v2": "^1.5.4", "ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0", "pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-drawio": "^1.0.3", "react-drawio": "^1.0.3",