e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
380 lines
9.7 KiB
TypeScript
380 lines
9.7 KiB
TypeScript
"use generative";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
WeatherWidget,
|
|
type TemperatureUnit,
|
|
type WeatherWidgetPayload,
|
|
} from "@/components/tool-ui/weather-widget/runtime";
|
|
import {
|
|
fetchWeatherWidgetFromOpenMeteo,
|
|
geocodeLocationWithOpenMeteo,
|
|
} from "@/lib/open-meteo-weather-adapter";
|
|
import { MapPin, CloudSun, AlertCircle } from "lucide-react";
|
|
import { z } from "zod";
|
|
import {
|
|
defineToolkit,
|
|
unstable_interactableTool,
|
|
useAuiState,
|
|
type ToolCallMessagePartComponent,
|
|
} from "@assistant-ui/react";
|
|
import {
|
|
JSONGenerativeUI,
|
|
defineGenerativeComponents,
|
|
generativeUIToJSX,
|
|
} from "@assistant-ui/react-generative-ui";
|
|
import { ToolErrorCard, ToolStatusCard, ToolTraceCard } from "@/lib/tool-trace";
|
|
import { Notepad } from "@/components/tool-ui/notepad";
|
|
|
|
const weatherFormatSchema = z.enum(["fahrenheit", "celsius"]);
|
|
|
|
const notepadSchema = z.object({
|
|
title: z.string().describe("A short title for the text."),
|
|
content: z.string().describe("The full plain text."),
|
|
});
|
|
|
|
type GeocodeLocationArgs = {
|
|
query: string;
|
|
};
|
|
|
|
type GeocodeLocationResult = Awaited<
|
|
ReturnType<typeof geocodeLocationWithOpenMeteo>
|
|
>;
|
|
|
|
type GetWeatherArgs = {
|
|
location: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
|
|
type GetWeatherResult =
|
|
| {
|
|
success: true;
|
|
id: string;
|
|
location: string;
|
|
widget: WeatherWidgetPayload;
|
|
}
|
|
| {
|
|
success: false;
|
|
error: string;
|
|
};
|
|
|
|
const GeocodeLocationToolUI: ToolCallMessagePartComponent<
|
|
GeocodeLocationArgs,
|
|
GeocodeLocationResult
|
|
> = ({ toolName, args, result }) => {
|
|
const icon = <MapPin className="size-4" />;
|
|
|
|
if (result?.success === false) {
|
|
return (
|
|
<ToolErrorCard signature={toolName} error={result.error} args={args} />
|
|
);
|
|
}
|
|
|
|
if (!result) {
|
|
return (
|
|
<ToolStatusCard
|
|
signature={toolName}
|
|
icon={icon}
|
|
message="Finding location..."
|
|
loading
|
|
/>
|
|
);
|
|
}
|
|
|
|
const { name, latitude, longitude } = result.result;
|
|
|
|
return (
|
|
<ToolTraceCard
|
|
icon={icon}
|
|
signature={toolName}
|
|
description={`${name} · ${latitude.toFixed(2)}, ${longitude.toFixed(2)}`}
|
|
args={args}
|
|
result={result}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const GetWeatherToolUI: ToolCallMessagePartComponent<
|
|
GetWeatherArgs,
|
|
GetWeatherResult
|
|
> = ({ toolName, args, result }) => {
|
|
const icon = <CloudSun className="size-4" />;
|
|
|
|
if (result?.success === false) {
|
|
return (
|
|
<ToolErrorCard signature={toolName} error={result.error} args={args} />
|
|
);
|
|
}
|
|
|
|
if (!result) {
|
|
return (
|
|
<ToolStatusCard
|
|
signature={toolName}
|
|
icon={icon}
|
|
message="Fetching weather..."
|
|
loading
|
|
/>
|
|
);
|
|
}
|
|
|
|
const current = result.widget?.current;
|
|
const unitSymbol = result.widget?.units.temperature === "celsius" ? "C" : "F";
|
|
|
|
return (
|
|
<ToolTraceCard
|
|
icon={icon}
|
|
signature={toolName}
|
|
description={
|
|
current
|
|
? `${Math.round(current.temperature)}°${unitSymbol} · ${current.conditionCode} in ${result.location}`
|
|
: "Weather ready"
|
|
}
|
|
args={args}
|
|
result={result}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// The user-facing component library the model renders through the `present`
|
|
// tool. `Weather` shows the rich card for a `get_weather` result by `id`.
|
|
const generative = new JSONGenerativeUI({
|
|
library: defineGenerativeComponents({
|
|
Weather: {
|
|
description:
|
|
"Show the user a rich weather card for a `get_weather` result.",
|
|
properties: z.object({
|
|
id: z.string().describe("The `id` returned by `get_weather`."),
|
|
format: weatherFormatSchema
|
|
.optional()
|
|
.describe("Temperature format to display in the weather card."),
|
|
}),
|
|
render: (props) => <WeatherCard {...props} />,
|
|
},
|
|
}),
|
|
});
|
|
|
|
export default defineToolkit({
|
|
// Weather data powered by Open-Meteo (https://open-meteo.com/)
|
|
geocode_location: {
|
|
description:
|
|
"Geocode a location name into latitude/longitude (Open-Meteo). Pass the " +
|
|
"coordinates to `get_weather`.",
|
|
parameters: z.object({
|
|
query: z.string(),
|
|
}),
|
|
execute: async ({ query }) => geocodeLocationWithOpenMeteo(query),
|
|
render: GeocodeLocationToolUI,
|
|
},
|
|
get_weather: {
|
|
description:
|
|
"Fetch the weather for coordinates from `geocode_location`. Returns an " +
|
|
'`id`; call `present` with `{ $type: "Weather", id }` to show the user a card.',
|
|
parameters: z.object({
|
|
location: z.string(),
|
|
latitude: z.number(),
|
|
longitude: z.number(),
|
|
}),
|
|
execute: async ({ location, latitude, longitude }) => {
|
|
const weather = await fetchWeatherWidgetFromOpenMeteo({
|
|
query: location,
|
|
latitude,
|
|
longitude,
|
|
});
|
|
if (!weather.success) {
|
|
return { success: false as const, error: weather.error };
|
|
}
|
|
|
|
return {
|
|
success: true as const,
|
|
id: crypto.randomUUID().slice(0, 8),
|
|
location,
|
|
widget: weather.widget,
|
|
};
|
|
},
|
|
render: GetWeatherToolUI,
|
|
},
|
|
present: generative.present({ display: "standalone" }),
|
|
notepad: unstable_interactableTool({
|
|
description:
|
|
"A live notepad whose drafted text the user sees and can edit. Open one " +
|
|
"whenever you write or draft prose for the user — a note, message, post, " +
|
|
"release notes, a description — and revise it with `update_notepad` " +
|
|
"rather than opening a new one. Opening the notepad and every " +
|
|
"`update_notepad` call display the latest draft to the user directly, so " +
|
|
"keep the text in the notepad and never repeat it in your reply.",
|
|
stateSchema: notepadSchema,
|
|
render: (props) => <Notepad {...props} />,
|
|
}),
|
|
});
|
|
|
|
const WeatherCard = ({
|
|
id,
|
|
format,
|
|
}: {
|
|
id: string;
|
|
format?: TemperatureUnit;
|
|
}) => {
|
|
// The payload lives on the `get_weather` result; the `Weather` component only
|
|
// carries the `id`. Scan the whole thread (the two calls usually land in
|
|
// separate assistant messages) for the matching result.
|
|
const source = useAuiState((s) => {
|
|
for (const message of s.thread.messages) {
|
|
for (const part of message.content) {
|
|
if (
|
|
part.type === "tool-call" &&
|
|
part.toolName === "get_weather" &&
|
|
(part.result as any)?.id === id
|
|
) {
|
|
return part.result as any;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
if (source?.success === false) {
|
|
return (
|
|
<ToolCard variant="error">
|
|
<ToolCardIcon>
|
|
<AlertCircle className="size-4" />
|
|
</ToolCardIcon>
|
|
<ToolCardContent>
|
|
<ToolCardTitle>Weather unavailable</ToolCardTitle>
|
|
<ToolCardDescription>{source.error}</ToolCardDescription>
|
|
</ToolCardContent>
|
|
</ToolCard>
|
|
);
|
|
}
|
|
|
|
if (!source?.widget) {
|
|
return (
|
|
<ToolCard>
|
|
<ToolCardIcon loading>
|
|
<CloudSun className="size-4" />
|
|
</ToolCardIcon>
|
|
<ToolCardContent>
|
|
<ToolCardTitle>Preparing weather...</ToolCardTitle>
|
|
</ToolCardContent>
|
|
</ToolCard>
|
|
);
|
|
}
|
|
|
|
const widget = format
|
|
? convertWeatherWidgetFormat(source.widget, format)
|
|
: source.widget;
|
|
const generativeNode = {
|
|
$type: "Weather",
|
|
id,
|
|
...(format !== undefined && { format }),
|
|
};
|
|
|
|
return (
|
|
<div className="mt-2 mb-2 flex max-w-sm flex-col">
|
|
<WeatherWidget {...widget} />
|
|
<p className="text-muted-foreground/70 mt-1.5 text-center font-mono text-xs">
|
|
present({generativeUIToJSX(generativeNode)})
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const convertTemperature = (
|
|
value: number,
|
|
from: TemperatureUnit,
|
|
to: TemperatureUnit,
|
|
) => {
|
|
if (from === to) return value;
|
|
return to === "celsius" ? ((value - 32) * 5) / 9 : (value * 9) / 5 + 32;
|
|
};
|
|
|
|
const convertWeatherWidgetFormat = (
|
|
widget: WeatherWidgetPayload,
|
|
format: TemperatureUnit,
|
|
): WeatherWidgetPayload => {
|
|
const sourceFormat = widget.units.temperature;
|
|
if (sourceFormat === format) return widget;
|
|
|
|
return {
|
|
...widget,
|
|
units: { ...widget.units, temperature: format },
|
|
current: {
|
|
...widget.current,
|
|
temperature: convertTemperature(
|
|
widget.current.temperature,
|
|
sourceFormat,
|
|
format,
|
|
),
|
|
tempMin: convertTemperature(widget.current.tempMin, sourceFormat, format),
|
|
tempMax: convertTemperature(widget.current.tempMax, sourceFormat, format),
|
|
},
|
|
forecast: widget.forecast.map((day) => ({
|
|
...day,
|
|
tempMin: convertTemperature(day.tempMin, sourceFormat, format),
|
|
tempMax: convertTemperature(day.tempMax, sourceFormat, format),
|
|
})),
|
|
};
|
|
};
|
|
|
|
// Shared Tool Card Components
|
|
const ToolCard = ({
|
|
children,
|
|
variant = "default",
|
|
}: {
|
|
children: React.ReactNode;
|
|
variant?: "default" | "error";
|
|
}) => (
|
|
<div
|
|
className={cn(
|
|
"my-2 flex items-center gap-3 rounded-lg border px-3 py-2.5",
|
|
variant === "error"
|
|
? "border-destructive/30 bg-destructive/5"
|
|
: "bg-muted/30",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const ToolCardDescription = ({ children }: { children: React.ReactNode }) => (
|
|
<span className="text-muted-foreground truncate text-xs">{children}</span>
|
|
);
|
|
|
|
const ToolCardIcon = ({
|
|
children,
|
|
loading = false,
|
|
}: {
|
|
children: React.ReactNode;
|
|
loading?: boolean;
|
|
}) => (
|
|
<div
|
|
className={cn(
|
|
"bg-background text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md shadow-sm",
|
|
loading && "animate-pulse",
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
const ToolCardContent = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex min-w-0 flex-col gap-0.5">{children}</div>
|
|
);
|
|
|
|
const ToolCardTitle = ({
|
|
children,
|
|
mono = false,
|
|
}: {
|
|
children: React.ReactNode;
|
|
mono?: boolean;
|
|
}) => (
|
|
<span
|
|
className={cn(
|
|
"truncate text-sm font-medium",
|
|
mono && "font-mono text-[13px] font-normal",
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|