feat: add WebSearchToolUI component and input UI, integrate Radix UI scroll area

This commit is contained in:
dayuan.jiang
2025-03-19 07:20:22 +00:00
parent 5d1a33b18d
commit 5a11c32bc4
8 changed files with 348 additions and 58 deletions

View File

@@ -1,43 +1,38 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { DrawIoEmbed, DrawIoEmbedRef } from "react-drawio";
import { Thread } from "@/components/assistant-ui/thread";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { extractDiagramXML } from "./extract_xml"
export default function Home() {
const runtime = useChatRuntime({
api: "/api/chat",
});
import { extractDiagramXML } from "./extract_xml";
import ChatPanel from "@/components/chatPanel";
export default function Home() {
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [imgData, setImgData] = useState<string | null>(null);
const [chartXML, setChartXML] = useState<string>("");
const [diagram, setDiagram] = useState<string>("");
// const handleExport = () => {};
const handleExport = () => {
// use this function to export the diagramxml from the drawio editor
if (drawioRef.current) {
drawioRef.current.exportDiagram({
format: "xmlsvg",
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const loadDiagram = (chart: string) => {
// use this function to display the diagramxml in the drawio editor
if (drawioRef.current) {
drawioRef.current.load({
xml: diagram,
xml: chart,
});
}
};
console.log("imgData", imgData);
return (
<AssistantRuntimeProvider runtime={runtime}>
<div className="grid h-dvh grid-cols-[1fr_400px] gap-x-2 px-4 py-4">
<div className="flex h-screen bg-gray-100">
<div className="w-2/3 p-1">
<DrawIoEmbed
ref={drawioRef}
onExport={(data) => setImgData(data.data)}
onExport={(data) => setChartXML(extractDiagramXML(data.data))}
urlParameters={{
// ui: "kennedy",
spin: true,
@@ -46,48 +41,10 @@ export default function Home() {
noExitBtn: true,
}}
/>
{/* <Thread /> */}
<div className="flex flex-col gap-2">
<form>
<div className="mb-4">
<label htmlFor="diagramXml" className="block text-sm font-medium mb-1">
Diagram XML
</label>
<textarea
id="diagramXml"
className="w-full p-2 border border-gray-300 rounded-md min-h-[200px]"
placeholder="Paste your diagram XML here..."
value={diagram || ''}
onChange={(e) => setDiagram(e.target.value)}
/>
</div>
<Button onClick={handleSubmit}>submit</Button>
</form>
<div>
<div className="bg-amber-300 w-full h-[400px]">{imgData &&
<>
{/* <img src={imgData} /> */}
<div className="bg-blue-100 h-[400px] p-4 overflow-auto">
<h1 className="font-semibold mb-2">Extracted XML</h1>
{(() => {
try {
const extractedXml = extractDiagramXML(imgData);
return <pre className="whitespace-pre-wrap text-sm">{extractedXml}</pre>;
} catch (error) {
return (
<div className="text-red-600">
Error extracting XML: {error instanceof Error ? error.message : 'Unknown error'}
<div className="w-1/3 p-1 border-gray-300 ">
<ChatPanel />
</div>
</div>
);
}
})()}
</div>
</>}
<Button onClick={handleExport}>Export</Button>
</div>
</div>
</div>
</div>
</AssistantRuntimeProvider>
);
}

87
components/chatPanel.tsx Normal file
View File

@@ -0,0 +1,87 @@
"use client"
import type React from "react"
import { useRef, useEffect } from "react"
import { useChat } from "@ai-sdk/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Loader2, Send } from "lucide-react"
export default function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: "/api/chat",
maxSteps: 5, // Allow multiple steps for complex diagram generation
})
const messagesEndRef = useRef<HTMLDivElement>(null)
// Scroll to bottom when messages change
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
}
}, [messages])
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (input.trim()) {
handleSubmit(e)
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-2">
<CardTitle>Chat with Diagram Generator</CardTitle>
</CardHeader>
<CardContent className="flex-grow overflow-hidden p-4">
<ScrollArea className="h-full pr-4">
{messages.length === 0 ? (
<div className="text-center text-gray-500 mt-8">
<p>Start a conversation to generate a diagram.</p>
<p className="text-sm mt-2">Try: "Create a flowchart for user authentication"</p>
</div>
) : (
messages.map((message) => (
<div key={message.id} className={`mb-4 ${message.role === "user" ? "text-right" : "text-left"}`}>
<div
className={`inline-block px-4 py-2 rounded-lg max-w-[85%] break-words ${message.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
}`}
>
{message.content}
</div>
{message.toolInvocations?.map((tool, index) => (
<div key={index} className="mt-2 text-left">
<div className="text-xs text-gray-500">
{tool.state === "call" ? "Generating diagram..." : "Diagram generated"}
</div>
</div>
))}
</div>
))
)}
<div ref={messagesEndRef} />
</ScrollArea>
</CardContent>
<CardFooter className="pt-2">
<form onSubmit={onFormSubmit} className="w-full flex space-x-2">
<Input
value={input}
onChange={handleInputChange}
placeholder="Describe the diagram you want to create..."
disabled={isLoading}
className="flex-grow"
/>
<Button type="submit" disabled={isLoading || !input.trim()}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</form>
</CardFooter>
</Card>
)
}

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-[data-slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,21 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
type WebSearchArgs = {
query: string;
};
type WebSearchResult = {
title: string;
description: string;
url: string;
};
export const WebSearchToolUI = makeAssistantToolUI<
WebSearchArgs,
WebSearchResult
>({
toolName: "web_search",
render: ({ args, status }) => {
return <p>web_search({args.query}) </p>;
},
});

53
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@assistant-ui/react": "^0.8.6",
"@assistant-ui/react-ai-sdk": "^0.8.0",
"@assistant-ui/react-markdown": "^0.8.0",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@xmldom/xmldom": "^0.9.8",
@@ -532,6 +533,12 @@
"node": ">=8.0.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
"integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
@@ -591,6 +598,21 @@
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
@@ -816,6 +838,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz",
"integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",

View File

@@ -14,6 +14,7 @@
"@assistant-ui/react": "^0.8.6",
"@assistant-ui/react-ai-sdk": "^0.8.0",
"@assistant-ui/react-markdown": "^0.8.0",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@xmldom/xmldom": "^0.9.8",