Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

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>
);