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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -45,72 +45,102 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--radius: 0.75rem;
/* Clean Light Modern Palette */
--background: oklch(0.985 0.002 240);
--foreground: oklch(0.23 0.02 260);
--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-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--popover-foreground: oklch(0.23 0.02 260);
/* Dark primary - slightly lighter */
--primary: oklch(0.35 0.01 260);
--primary-foreground: oklch(0.99 0 0);
/* Warm gray secondary */
--secondary: oklch(0.96 0.005 260);
--secondary-foreground: oklch(0.35 0.02 260);
/* Light muted tones */
--muted: oklch(0.965 0.005 260);
--muted-foreground: oklch(0.50 0.02 260);
/* Soft lavender accent */
--accent: oklch(0.94 0.03 280);
--accent-foreground: oklch(0.35 0.08 270);
/* Coral destructive */
--destructive: oklch(0.60 0.20 25);
/* Subtle borders */
--border: oklch(0.92 0.01 260);
--input: oklch(0.94 0.01 260);
--ring: oklch(0.25 0.01 260);
/* 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 {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.15 0.015 260);
--foreground: oklch(0.95 0.01 260);
--card: oklch(0.20 0.015 260);
--card-foreground: oklch(0.95 0.01 260);
--popover: oklch(0.20 0.015 260);
--popover-foreground: oklch(0.95 0.01 260);
--primary: oklch(0.70 0.16 265);
--primary-foreground: oklch(0.15 0.02 260);
--secondary: oklch(0.25 0.015 260);
--secondary-foreground: oklch(0.90 0.01 260);
--muted: oklch(0.25 0.015 260);
--muted-foreground: oklch(0.65 0.02 260);
--accent: oklch(0.30 0.04 280);
--accent-foreground: oklch(0.90 0.03 270);
--destructive: oklch(0.65 0.22 25);
--border: oklch(0.28 0.015 260);
--input: oklch(0.25 0.015 260);
--ring: oklch(0.70 0.16 265);
--chart-1: oklch(0.70 0.16 265);
--chart-2: oklch(0.70 0.13 170);
--chart-3: oklch(0.75 0.16 45);
--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 {
@@ -118,6 +148,101 @@
@apply border-border outline-ring/50;
}
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 { Geist, Geist_Mono } from "next/font/google";
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/react";
import { GoogleAnalytics } from "@next/third-parties/google";
import { DiagramProvider } from "@/contexts/diagram-context";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-sans",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
weight: ["400", "500"],
});
export const metadata: Metadata = {
@@ -91,7 +93,7 @@ export default function RootLayout({
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DiagramProvider>{children}</DiagramProvider>

View File

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

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({
setInput,
setFiles,
@@ -5,80 +39,85 @@ export default function ExamplePanel({
setInput: (input: string) => void;
setFiles: (files: File[]) => void;
}) {
// New handler for the "Replicate this flowchart" button
const handleReplicateFlowchart = async () => {
setInput("Replicate this flowchart.");
try {
// Fetch the example image
const response = await fetch("/example.png");
const blob = await response.blob();
const file = new File([blob], "example.png", { type: "image/png" });
// Set the file to the files state
setFiles([file]);
} catch (error) {
console.error("Error loading example image:", error);
}
};
// Handler for the "Replicate this in aws style" button
const handleReplicateArchitecture = async () => {
setInput("Replicate this in aws style");
try {
// Fetch the architecture image
const response = await fetch("/architecture.png");
const blob = await response.blob();
const file = new File([blob], "architecture.png", {
type: "image/png",
});
// Set the file to the files state
setFiles([file]);
} catch (error) {
console.error("Error loading architecture image:", error);
}
};
return (
<div className="px-4 py-2 border-t border-b border-gray-100">
<p className="text-sm text-gray-500 mb-2">
{" "}
Start a conversation to generate or modify diagrams.
</p>
<p className="text-sm text-gray-500 mb-2">
{" "}
You can also upload images to use as references.
</p>
<p className="text-sm text-gray-500 mb-2">
Try these examples{" "}
<span className="text-xs text-gray-400">(cached for instant response)</span>:
</p>
<div className="flex flex-wrap gap-5">
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
>
Draw diagram with Animated Connectors
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={handleReplicateArchitecture}
>
Create AWS architecture
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={handleReplicateFlowchart}
>
Replicate flowchart
</button>
<button
className="text-xs bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-2 rounded"
onClick={() => setInput("Draw a cat for me")}
>
Draw a cat
</button>
<div className="py-6 px-2 animate-fade-in">
{/* Welcome section */}
<div className="text-center mb-6">
<h2 className="text-lg font-semibold text-foreground mb-2">
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
</p>
</div>
{/* Examples grid */}
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
Quick Examples
</p>
<div className="grid gap-2">
<ExampleCard
icon={<Zap className="w-4 h-4 text-primary" />}
title="Animated Diagram"
description="Draw a transformer architecture with animated connectors"
onClick={() => setInput("Give me a **animated connector** diagram of transformer's architecture")}
/>
<ExampleCard
icon={<Cloud className="w-4 h-4 text-primary" />}
title="AWS Architecture"
description="Create a cloud architecture diagram with AWS icons"
onClick={handleReplicateArchitecture}
/>
<ExampleCard
icon={<GitBranch className="w-4 h-4 text-primary" />}
title="Replicate Flowchart"
description="Upload and replicate an existing flowchart"
onClick={handleReplicateFlowchart}
/>
<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>
);

View File

@@ -12,6 +12,7 @@ import {
Image as ImageIcon,
History,
Download,
Paperclip,
} from "lucide-react";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
@@ -48,13 +49,12 @@ export function ChatInput({
const [showClearDialog, setShowClearDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Debug: Log status changes
const isDisabled = status === "streaming" || status === "submitted";
useEffect(() => {
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
// Auto-resize textarea based on content
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
@@ -67,7 +67,6 @@ export function ChatInput({
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
// Handle keyboard shortcuts and paste events
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
@@ -78,7 +77,6 @@ export function ChatInput({
}
};
// Handle clipboard paste
const handlePaste = async (e: React.ClipboardEvent) => {
if (isDisabled) return;
@@ -92,7 +90,6 @@ export function ChatInput({
imageItems.map(async (item) => {
const file = item.getAsFile();
if (!file) return null;
// Create a new file with a unique name
return new File(
[file],
`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 newFiles = Array.from(e.target.files || []);
onFileChange([...files, ...newFiles]);
};
// Remove individual file
const handleRemoveFile = (fileToRemove: File) => {
onFileChange(files.filter((file) => file !== fileToRemove));
if (fileInputRef.current) {
@@ -126,12 +121,10 @@ export function ChatInput({
}
};
// Trigger file input click
const triggerFileInput = () => {
fileInputRef.current?.click();
};
// Handle drag events
const handleDragOver = (e: React.DragEvent<HTMLFormElement>) => {
e.preventDefault();
e.stopPropagation();
@@ -153,7 +146,6 @@ export function ChatInput({
const droppedFiles = e.dataTransfer.files;
// Only process image files
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/")
);
@@ -163,141 +155,148 @@ export function ChatInput({
}
};
// Handle clearing conversation and diagram
const handleClear = () => {
onClearChat();
setShowClearDialog(false);
};
return (
<form
onSubmit={onSubmit}
className={`w-full space-y-2 ${
className={`w-full transition-all duration-200 ${
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}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
<Textarea
ref={textareaRef}
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}
/>
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
</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 */}
<ButtonWithTooltip
type="button"
variant="outline"
size="icon"
onClick={() => setShowSaveDialog(true)}
disabled={isDisabled}
tooltipContent="Save diagram to local file"
>
<Download className="h-4 w-4" />
</ButtonWithTooltip>
{/* Input container */}
<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">
<Textarea
ref={textareaRef}
value={input}
onChange={onChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe your diagram or paste an image..."
disabled={isDisabled}
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
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={saveDiagramToFile}
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
/>
{/* Action bar */}
<div className="flex items-center justify-between px-3 py-2 border-t border-border/50">
{/* Left actions */}
<div className="flex items-center gap-1">
<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
type="button"
variant="outline"
size="icon"
onClick={triggerFileInput}
disabled={isDisabled}
title="Upload image"
>
<ImageIcon className="h-4 w-4" />
</Button>
<ResetWarningModal
open={showClearDialog}
onOpenChange={setShowClearDialog}
onClear={handleClear}
/>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
disabled={isDisabled}
/>
<HistoryDialog
showHistory={showHistory}
onToggleHistory={onToggleHistory}
/>
</div>
{/* Right actions */}
<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>
<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>
</form>
);
}

View File

@@ -6,7 +6,51 @@ 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 } 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";
@@ -60,10 +104,9 @@ export function ChatMessageDisplay({
if (convertedXml !== previousXML.current) {
const replacedXML = replaceNodes(chartXML, convertedXml);
// Validate before sending to draw.io to prevent "d.setId is not a function" errors
const validationError = validateMxCellStructure(replacedXML);
if (!validationError) {
previousXML.current = convertedXml; // Only update on success
previousXML.current = convertedXml;
onDisplayChart(replacedXML);
} else {
console.error("[ChatMessageDisplay] XML validation failed:", validationError);
@@ -79,7 +122,6 @@ export function ChatMessageDisplay({
}
}, [messages]);
// Handle tool invocations and update diagram when needed
useEffect(() => {
messages.forEach((message) => {
if (message.parts) {
@@ -87,7 +129,6 @@ export function ChatMessageDisplay({
if (part.type?.startsWith("tool-")) {
const { toolCallId, state } = part;
// Auto-collapse args when diagrams are generated
if (state === "output-available") {
setExpandedTools((prev) => ({
...prev,
@@ -95,20 +136,16 @@ export function ChatMessageDisplay({
}));
}
// Handle diagram updates for display_diagram tool
if (
part.type === "tool-display_diagram" &&
part.input?.xml
) {
// For streaming input, always update to show streaming
if (
state === "input-streaming" ||
state === "input-available"
) {
handleDisplayChart(part.input.xml);
}
// For completed calls, only update if not processed yet
else if (
} else if (
state === "output-available" &&
!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 (
<div
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">
<div className="text-xs">Tool: {toolName}</div>
<div className="flex items-center justify-between px-4 py-3 bg-muted/50">
<div className="flex items-center gap-2">
<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 && (
<button
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>
)}
</div>
{input && isExpanded && (
<pre className="mt-1 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-48">
{typeof input === "object" &&
Object.keys(input).length > 0 &&
`Input: ${JSON.stringify(input, null, 2)}`}
</pre>
)}
<div className="mt-2 text-sm">
{state === "input-streaming" ? (
<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>
</div>
{input && isExpanded && (
<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) ? (
<EditDiffDisplay edits={input.edits} />
) : typeof input === "object" && Object.keys(input).length > 0 ? (
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
) : null}
</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>
);
};
return (
<ScrollArea className="h-full pr-4">
<ScrollArea className="h-full px-4 scrollbar-thin">
{messages.length === 0 ? (
<ExamplePanel setInput={setInput} setFiles={setFiles} />
) : (
messages.map((message) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
return (
<div
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 className="py-4 space-y-4">
{messages.map((message, messageIndex) => {
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
return (
<div
className={`px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"} animate-message-in`}
style={{ animationDelay: `${messageIndex * 50}ms` }}
>
{message.parts?.map((part: any, index: number) => {
switch (part.type) {
case "text":
return (
<div key={index}>{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-md border"
style={{
objectFit: "contain",
}}
/>
</div>
);
default:
if (part.type?.startsWith("tool-")) {
return renderToolPart(part);
}
return null;
}
})}
{message.role === "user" && userMessageText && (
<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 self-center mr-2"
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 className="max-w-[85%]">
{/* Text content in bubble */}
{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"
: "bg-muted/60 text-foreground rounded-2xl rounded-bl-md"
}`}
>
{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>
)}
{error && (
<div className="text-red-500 text-sm mt-2">
Error: {error.message}
<div className="mx-4 mb-4 p-4 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm">
<span className="font-medium">Error:</span> {error.message}
</div>
)}
<div ref={messagesEndRef} />

View File

@@ -5,14 +5,8 @@ import { useRef, useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa";
import { PanelRightClose, PanelRightOpen } from "lucide-react";
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 { DefaultChatTransport } from "ai";
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[]>([]);
// Add state for showing the history dialog
const [showHistory, setShowHistory] = useState(false);
// Convert File[] to FileList for experimental_attachments
const createFileList = (files: File[]): FileList => {
const dt = new DataTransfer();
files.forEach((file) => dt.items.add(file));
return dt.files;
};
// Add state for input management
const [input, setInput] = useState("");
// Remove the currentXmlRef and related useEffect
const { messages, sendMessage, addToolResult, status, error, setMessages } =
useChat({
transport: new DefaultChatTransport({
@@ -84,7 +65,6 @@ export default function ChatPanel({
if (toolCall.toolName === "display_diagram") {
const { xml } = toolCall.input as { xml: string };
// Validate XML structure before confirming success
const validationError = validateMxCellStructure(xml);
if (validationError) {
@@ -107,14 +87,11 @@ export default function ChatPanel({
let currentXml = "";
try {
// Fetch current chart XML
currentXml = await onFetchChart();
// Apply edits using the utility function
const { replaceXMLParts } = await import("@/lib/utils");
const editedXml = replaceXMLParts(currentXml, edits);
// Load the edited diagram
onDisplayChart(editedXml);
addToolResult({
@@ -130,7 +107,6 @@ export default function ChatPanel({
? error.message
: String(error);
// Provide detailed error with current diagram XML
addToolResult({
tool: "edit_diagram",
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);
},
});
const messagesEndRef = useRef<HTMLDivElement>(null);
// Scroll to bottom when messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Debug: Log status changes
useEffect(() => {
console.log("[ChatPanel] Status changed to:", 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";
if (input.trim() && !isProcessing) {
try {
// Fetch chart data before sending message
let chartXml = await onFetchChart();
// Format the XML to ensure consistency
chartXml = formatXML(chartXml);
// Create message parts
const parts: any[] = [{ type: "text", text: input }];
// Add file parts if files exist
if (files.length > 0) {
for (const file of files) {
const reader = new FileReader();
@@ -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("");
setFiles([]);
} 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 = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setInput(e.target.value);
};
// Helper function to handle file changes
const handleFileChange = (newFiles: File[]) => {
setFiles(newFiles);
};
// Collapsed view when chat is hidden
// Collapsed view
if (!isVisible) {
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
tooltipContent="Show chat panel (Ctrl+B)"
variant="ghost"
size="icon"
onClick={onToggleVisibility}
className="hover:bg-accent transition-colors"
>
<PanelRightOpen className="h-5 w-5" />
<PanelRightOpen className="h-5 w-5 text-muted-foreground" />
</ButtonWithTooltip>
<div
className="text-sm text-gray-500 mt-8"
className="text-sm font-medium text-muted-foreground mt-8 tracking-wide"
style={{
writingMode: "vertical-rl",
transform: "rotate(180deg)",
}}
>
Chat
AI Chat
</div>
</Card>
</div>
);
}
// Full view when chat is visible
// Full view
return (
<Card className="h-full flex flex-col rounded-none py-0 gap-0">
<CardHeader className="p-4 flex flex-row justify-between items-center">
<div className="flex items-center gap-3">
<CardTitle>Next-AI-Drawio</CardTitle>
<Link
href="/about"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
About
</Link>
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
{/* Header */}
<header className="px-5 py-4 border-b border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Image
src="/favicon.ico"
alt="Next AI Drawio"
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 className="flex items-center gap-2">
<a
href="https://github.com/DayuanJiang/next-ai-draw-io"
target="_blank"
rel="noopener noreferrer"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<FaGithub className="w-6 h-6" />
</a>
<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">
</header>
{/* Messages */}
<main className="flex-1 overflow-hidden">
<ChatMessageDisplay
messages={messages}
error={error}
setInput={setInput}
setFiles={handleFileChange}
/>
</CardContent>
</main>
<CardFooter className="p-2">
{/* Input */}
<footer className="p-4 border-t border-border/50 bg-card/50">
<ChatInput
input={input}
status={status}
@@ -306,7 +293,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
showHistory={showHistory}
onToggleHistory={setShowHistory}
/>
</CardFooter>
</Card>
</footer>
</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",
"ollama-ai-provider-v2": "^1.5.4",
"pako": "^2.1.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-drawio": "^1.0.3",
@@ -2792,6 +2793,12 @@
"dev": true,
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
@@ -7867,6 +7874,19 @@
"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": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

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