From be7cc3eebcd3a8b59e628e2354c143ff71389933 Mon Sep 17 00:00:00 2001 From: Karol Chudzik Date: Tue, 14 Jan 2025 14:28:37 +0100 Subject: [PATCH] feat(chat-message): add tool call visualization --- apps/www/app/api/chat/route.ts | 17 +++- apps/www/lib/weather.ts | 84 +++++++++++++++++++ apps/www/public/r/chat-message.json | 2 +- apps/www/registry/default/ui/chat-message.tsx | 75 ++++++++++++++++- 4 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 apps/www/lib/weather.ts diff --git a/apps/www/app/api/chat/route.ts b/apps/www/app/api/chat/route.ts index 4214aab..b743090 100644 --- a/apps/www/app/api/chat/route.ts +++ b/apps/www/app/api/chat/route.ts @@ -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 @@ -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() diff --git a/apps/www/lib/weather.ts b/apps/www/lib/weather.ts new file mode 100644 index 0000000..9679551 --- /dev/null +++ b/apps/www/lib/weather.ts @@ -0,0 +1,84 @@ +// Map weather code to description +const weatherCodes: Record = { + 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 { + 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 { + // 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}¤t=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", + } +} diff --git a/apps/www/public/r/chat-message.json b/apps/www/public/r/chat-message.json index ab1a6b2..565ff59 100644 --- a/apps/www/public/r/chat-message.json +++ b/apps/www/public/r/chat-message.json @@ -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[\"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 = ({\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
\n {files ? (\n
\n {files.map((file, index) => {\n return \n })}\n
\n ) : null}\n\n
\n
\n {content}\n
\n\n {role === \"assistant\" && actions ? (\n
\n {actions}\n
\n ) : null}\n
\n\n {showTimeStamp && createdAt ? (\n \n {formattedTime}\n \n ) : null}\n
\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[\"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 = ({\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 \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
\n {files ? (\n
\n {files.map((file, index) => {\n return \n })}\n
\n ) : null}\n\n
\n
\n {content}\n
\n\n {role === \"assistant\" && actions ? (\n
\n {actions}\n
\n ) : null}\n
\n\n {showTimeStamp && createdAt ? (\n \n {formattedTime}\n \n ) : null}\n
\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) {\n if (!toolInvocations?.length) return null\n\n return (\n
\n {toolInvocations.map((invocation, index) => {\n switch (invocation.state) {\n case \"partial-call\":\n case \"call\":\n return (\n \n \n Calling {invocation.toolName}...\n \n
\n )\n case \"result\":\n return (\n \n
\n \n Result from {invocation.toolName}\n
\n
\n                  {JSON.stringify(invocation.result, null, 2)}\n                
\n \n )\n }\n })}\n \n )\n}\n", "type": "registry:ui", "target": "" } diff --git a/apps/www/registry/default/ui/chat-message.tsx b/apps/www/registry/default/ui/chat-message.tsx index 35fa7bb..d01af96 100644 --- a/apps/www/registry/default/ui/chat-message.tsx +++ b/apps/www/registry/default/ui/chat-message.tsx @@ -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" @@ -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 { @@ -79,9 +99,8 @@ export const ChatMessage: React.FC = ({ actions, className, experimental_attachments, + toolInvocations, }) => { - const isUser = role === "user" - const files = useMemo(() => { return experimental_attachments?.map((attachment) => { const dataArray = dataUrlToUint8Array(attachment.url) @@ -90,6 +109,12 @@ export const ChatMessage: React.FC = ({ }) }, [experimental_attachments]) + if (toolInvocations && toolInvocations.length > 0) { + return + } + + const isUser = role === "user" + const formattedTime = createdAt?.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", @@ -98,7 +123,7 @@ export const ChatMessage: React.FC = ({ return (
{files ? ( -
+
{files.map((file, index) => { return })} @@ -111,7 +136,7 @@ export const ChatMessage: React.FC = ({
{role === "assistant" && actions ? ( -
+
{actions}
) : null} @@ -137,3 +162,45 @@ function dataUrlToUint8Array(data: string) { const buf = Buffer.from(base64, "base64") return new Uint8Array(buf) } + +function ToolCall({ + toolInvocations, +}: Pick) { + if (!toolInvocations?.length) return null + + return ( +
+ {toolInvocations.map((invocation, index) => { + switch (invocation.state) { + case "partial-call": + case "call": + return ( +
+ + Calling {invocation.toolName}... + +
+ ) + case "result": + return ( +
+
+ + Result from {invocation.toolName} +
+
+                  {JSON.stringify(invocation.result, null, 2)}
+                
+
+ ) + } + })} +
+ ) +}