Skip to content

Commit

Permalink
feat(chat-message): add tool call visualization
Browse files Browse the repository at this point in the history
  • Loading branch information
iipanda committed Jan 14, 2025
1 parent 7ea9eac commit be7cc3e
Show file tree
Hide file tree
Showing 4 changed files with 172 additions and 6 deletions.
17 changes: 16 additions & 1 deletion apps/www/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createOpenAI } from "@ai-sdk/openai"
import { convertToCoreMessages, streamText } from "ai"
import { convertToCoreMessages, streamText, tool } from "ai"
import { z } from "zod"

import { getWeather } from "@/lib/weather"

export const maxDuration = 30

Expand All @@ -15,6 +18,18 @@ export async function POST(req: Request) {
const result = streamText({
model: groq("llama-3.3-70b-versatile"),
messages: convertToCoreMessages(messages),
maxSteps: 3,
tools: {
weather: tool({
description: "Look up the weather in a given location",
parameters: z.object({
location: z.string().describe("The location to get the weather for"),
}),
execute: async ({ location }) => {
return await getWeather(location)
},
}),
},
})

return result.toDataStreamResponse()
Expand Down
84 changes: 84 additions & 0 deletions apps/www/lib/weather.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Map weather code to description
const weatherCodes: Record<number, string> = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}

interface Coordinates {
lat: number
lon: number
name: string
country: string
}

interface WeatherData {
location: string
temperature: string
feelsLike: string
humidity: string
windSpeed: string
precipitation: string
conditions: string
}

export async function getCoordinates(location: string): Promise<Coordinates> {
const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(
location
)}&count=1&language=en&format=json`
)
const data = await response.json()
if (!data.results?.[0]) {
throw new Error(`Location not found: ${location}`)
}
return {
lat: data.results[0].latitude,
lon: data.results[0].longitude,
name: data.results[0].name,
country: data.results[0].country,
}
}

export async function getWeather(location: string): Promise<WeatherData> {
// Get coordinates for the location
const coords = await getCoordinates(location)

// Fetch weather data
const weatherResponse = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${coords.lat}&longitude=${coords.lon}&current=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m&timezone=auto`
)
const weatherData = await weatherResponse.json()

const current = weatherData.current
return {
location: `${coords.name}, ${coords.country}`,
temperature: `${current.temperature_2m}°C`,
feelsLike: `${current.apparent_temperature}°C`,
humidity: `${current.relative_humidity_2m}%`,
windSpeed: `${current.wind_speed_10m} km/h`,
precipitation: `${current.precipitation} mm`,
conditions: weatherCodes[current.weather_code] || "Unknown",
}
}
2 changes: 1 addition & 1 deletion apps/www/public/r/chat-message.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"files": [
{
"path": "ui/chat-message.tsx",
"content": "\"use client\"\n\nimport React, { useMemo } from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { FilePreview } from \"@/registry/default/ui/file-preview\"\nimport { MarkdownRenderer } from \"@/registry/default/ui/markdown-renderer\"\n\nconst chatBubbleVariants = cva(\n \"group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]\",\n {\n variants: {\n isUser: {\n true: \"bg-primary text-primary-foreground\",\n false: \"bg-muted text-foreground\",\n },\n animation: {\n none: \"\",\n slide: \"duration-300 animate-in fade-in-0\",\n scale: \"duration-300 animate-in fade-in-0 zoom-in-75\",\n fade: \"duration-500 animate-in fade-in-0\",\n },\n },\n compoundVariants: [\n {\n isUser: true,\n animation: \"slide\",\n class: \"slide-in-from-right\",\n },\n {\n isUser: false,\n animation: \"slide\",\n class: \"slide-in-from-left\",\n },\n {\n isUser: true,\n animation: \"scale\",\n class: \"origin-bottom-right\",\n },\n {\n isUser: false,\n animation: \"scale\",\n class: \"origin-bottom-left\",\n },\n ],\n }\n)\n\ntype Animation = VariantProps<typeof chatBubbleVariants>[\"animation\"]\n\ninterface Attachment {\n name?: string\n contentType?: string\n url: string\n}\n\nexport interface Message {\n id: string\n role: \"user\" | \"assistant\" | (string & {})\n content: string\n createdAt?: Date\n experimental_attachments?: Attachment[]\n}\n\nexport interface ChatMessageProps extends Message {\n showTimeStamp?: boolean\n animation?: Animation\n actions?: React.ReactNode\n className?: string\n}\n\nexport const ChatMessage: React.FC<ChatMessageProps> = ({\n role,\n content,\n createdAt,\n showTimeStamp = false,\n animation = \"scale\",\n actions,\n className,\n experimental_attachments,\n}) => {\n const isUser = role === \"user\"\n\n const files = useMemo(() => {\n return experimental_attachments?.map((attachment) => {\n const dataArray = dataUrlToUint8Array(attachment.url)\n const file = new File([dataArray], attachment.name ?? \"Unknown\")\n return file\n })\n }, [experimental_attachments])\n\n const formattedTime = createdAt?.toLocaleTimeString(\"en-US\", {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n\n return (\n <div className={cn(\"flex flex-col\", isUser ? \"items-end\" : \"items-start\")}>\n {files ? (\n <div className=\"mb-1 flex gap-2 flex-wrap\">\n {files.map((file, index) => {\n return <FilePreview file={file} key={index} />\n })}\n </div>\n ) : null}\n\n <div className={cn(chatBubbleVariants({ isUser, animation }), className)}>\n <div>\n <MarkdownRenderer>{content}</MarkdownRenderer>\n </div>\n\n {role === \"assistant\" && actions ? (\n <div className=\"absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 opacity-0 transition-opacity group-hover/message:opacity-100 text-foreground\">\n {actions}\n </div>\n ) : null}\n </div>\n\n {showTimeStamp && createdAt ? (\n <time\n dateTime={createdAt.toISOString()}\n className={cn(\n \"mt-1 block px-1 text-xs opacity-50\",\n animation !== \"none\" && \"duration-500 animate-in fade-in-0\"\n )}\n >\n {formattedTime}\n </time>\n ) : null}\n </div>\n )\n}\n\nfunction dataUrlToUint8Array(data: string) {\n const base64 = data.split(\",\")[1]\n const buf = Buffer.from(base64, \"base64\")\n return new Uint8Array(buf)\n}\n",
"content": "\"use client\"\n\nimport React, { useMemo } from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Code2, Loader2, Terminal } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { FilePreview } from \"@/registry/default/ui/file-preview\"\nimport { MarkdownRenderer } from \"@/registry/default/ui/markdown-renderer\"\n\nconst chatBubbleVariants = cva(\n \"group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]\",\n {\n variants: {\n isUser: {\n true: \"bg-primary text-primary-foreground\",\n false: \"bg-muted text-foreground\",\n },\n animation: {\n none: \"\",\n slide: \"duration-300 animate-in fade-in-0\",\n scale: \"duration-300 animate-in fade-in-0 zoom-in-75\",\n fade: \"duration-500 animate-in fade-in-0\",\n },\n },\n compoundVariants: [\n {\n isUser: true,\n animation: \"slide\",\n class: \"slide-in-from-right\",\n },\n {\n isUser: false,\n animation: \"slide\",\n class: \"slide-in-from-left\",\n },\n {\n isUser: true,\n animation: \"scale\",\n class: \"origin-bottom-right\",\n },\n {\n isUser: false,\n animation: \"scale\",\n class: \"origin-bottom-left\",\n },\n ],\n }\n)\n\ntype Animation = VariantProps<typeof chatBubbleVariants>[\"animation\"]\n\ninterface Attachment {\n name?: string\n contentType?: string\n url: string\n}\n\ninterface PartialToolCall {\n state: \"partial-call\"\n toolName: string\n}\n\ninterface ToolCall {\n state: \"call\"\n toolName: string\n}\n\ninterface ToolResult {\n state: \"result\"\n toolName: string\n result: any\n}\n\ntype ToolInvocation = PartialToolCall | ToolCall | ToolResult\n\nexport interface Message {\n id: string\n role: \"user\" | \"assistant\" | (string & {})\n content: string\n createdAt?: Date\n experimental_attachments?: Attachment[]\n toolInvocations?: ToolInvocation[]\n}\n\nexport interface ChatMessageProps extends Message {\n showTimeStamp?: boolean\n animation?: Animation\n actions?: React.ReactNode\n className?: string\n}\n\nexport const ChatMessage: React.FC<ChatMessageProps> = ({\n role,\n content,\n createdAt,\n showTimeStamp = false,\n animation = \"scale\",\n actions,\n className,\n experimental_attachments,\n toolInvocations,\n}) => {\n const files = useMemo(() => {\n return experimental_attachments?.map((attachment) => {\n const dataArray = dataUrlToUint8Array(attachment.url)\n const file = new File([dataArray], attachment.name ?? \"Unknown\")\n return file\n })\n }, [experimental_attachments])\n\n if (toolInvocations && toolInvocations.length > 0) {\n return <ToolCall toolInvocations={toolInvocations} />\n }\n\n const isUser = role === \"user\"\n\n const formattedTime = createdAt?.toLocaleTimeString(\"en-US\", {\n hour: \"2-digit\",\n minute: \"2-digit\",\n })\n\n return (\n <div className={cn(\"flex flex-col\", isUser ? \"items-end\" : \"items-start\")}>\n {files ? (\n <div className=\"mb-1 flex flex-wrap gap-2\">\n {files.map((file, index) => {\n return <FilePreview file={file} key={index} />\n })}\n </div>\n ) : null}\n\n <div className={cn(chatBubbleVariants({ isUser, animation }), className)}>\n <div>\n <MarkdownRenderer>{content}</MarkdownRenderer>\n </div>\n\n {role === \"assistant\" && actions ? (\n <div className=\"absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100\">\n {actions}\n </div>\n ) : null}\n </div>\n\n {showTimeStamp && createdAt ? (\n <time\n dateTime={createdAt.toISOString()}\n className={cn(\n \"mt-1 block px-1 text-xs opacity-50\",\n animation !== \"none\" && \"duration-500 animate-in fade-in-0\"\n )}\n >\n {formattedTime}\n </time>\n ) : null}\n </div>\n )\n}\n\nfunction dataUrlToUint8Array(data: string) {\n const base64 = data.split(\",\")[1]\n const buf = Buffer.from(base64, \"base64\")\n return new Uint8Array(buf)\n}\n\nfunction ToolCall({\n toolInvocations,\n}: Pick<ChatMessageProps, \"toolInvocations\">) {\n if (!toolInvocations?.length) return null\n\n return (\n <div className=\"flex flex-col items-start gap-2\">\n {toolInvocations.map((invocation, index) => {\n switch (invocation.state) {\n case \"partial-call\":\n case \"call\":\n return (\n <div\n key={index}\n className=\"flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-sm text-muted-foreground\"\n >\n <Terminal className=\"h-4 w-4\" />\n <span>Calling {invocation.toolName}...</span>\n <Loader2 className=\"h-3 w-3 animate-spin\" />\n </div>\n )\n case \"result\":\n return (\n <div\n key={index}\n className=\"flex flex-col gap-1.5 rounded-lg border bg-muted px-3 py-2 text-sm\"\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n <Code2 className=\"h-4 w-4\" />\n <span>Result from {invocation.toolName}</span>\n </div>\n <pre className=\"overflow-x-auto whitespace-pre-wrap text-foreground\">\n {JSON.stringify(invocation.result, null, 2)}\n </pre>\n </div>\n )\n }\n })}\n </div>\n )\n}\n",
"type": "registry:ui",
"target": ""
}
Expand Down
75 changes: 71 additions & 4 deletions apps/www/registry/default/ui/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Code2, Loader2, Terminal } from "lucide-react"

import { cn } from "@/lib/utils"
import { FilePreview } from "@/registry/default/ui/file-preview"
Expand Down Expand Up @@ -55,12 +56,31 @@ interface Attachment {
url: string
}

interface PartialToolCall {
state: "partial-call"
toolName: string
}

interface ToolCall {
state: "call"
toolName: string
}

interface ToolResult {
state: "result"
toolName: string
result: any
}

type ToolInvocation = PartialToolCall | ToolCall | ToolResult

export interface Message {
id: string
role: "user" | "assistant" | (string & {})
content: string
createdAt?: Date
experimental_attachments?: Attachment[]
toolInvocations?: ToolInvocation[]
}

export interface ChatMessageProps extends Message {
Expand All @@ -79,9 +99,8 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
actions,
className,
experimental_attachments,
toolInvocations,
}) => {
const isUser = role === "user"

const files = useMemo(() => {
return experimental_attachments?.map((attachment) => {
const dataArray = dataUrlToUint8Array(attachment.url)
Expand All @@ -90,6 +109,12 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
})
}, [experimental_attachments])

if (toolInvocations && toolInvocations.length > 0) {
return <ToolCall toolInvocations={toolInvocations} />
}

const isUser = role === "user"

const formattedTime = createdAt?.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
Expand All @@ -98,7 +123,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
return (
<div className={cn("flex flex-col", isUser ? "items-end" : "items-start")}>
{files ? (
<div className="mb-1 flex gap-2 flex-wrap">
<div className="mb-1 flex flex-wrap gap-2">
{files.map((file, index) => {
return <FilePreview file={file} key={index} />
})}
Expand All @@ -111,7 +136,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</div>

{role === "assistant" && actions ? (
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 opacity-0 transition-opacity group-hover/message:opacity-100 text-foreground">
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100">
{actions}
</div>
) : null}
Expand All @@ -137,3 +162,45 @@ function dataUrlToUint8Array(data: string) {
const buf = Buffer.from(base64, "base64")
return new Uint8Array(buf)
}

function ToolCall({
toolInvocations,
}: Pick<ChatMessageProps, "toolInvocations">) {
if (!toolInvocations?.length) return null

return (
<div className="flex flex-col items-start gap-2">
{toolInvocations.map((invocation, index) => {
switch (invocation.state) {
case "partial-call":
case "call":
return (
<div
key={index}
className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-sm text-muted-foreground"
>
<Terminal className="h-4 w-4" />
<span>Calling {invocation.toolName}...</span>
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)
case "result":
return (
<div
key={index}
className="flex flex-col gap-1.5 rounded-lg border bg-muted px-3 py-2 text-sm"
>
<div className="flex items-center gap-2 text-muted-foreground">
<Code2 className="h-4 w-4" />
<span>Result from {invocation.toolName}</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-foreground">
{JSON.stringify(invocation.result, null, 2)}
</pre>
</div>
)
}
})}
</div>
)
}

0 comments on commit be7cc3e

Please sign in to comment.