@x-otto/plugin-weather 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plugin.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * plugin.ts —— plugin-weather 代码式插件入口(RFC-105 D4 tools 轴)。
3
+ *
4
+ * 单一工具 `get_weather`:经 Open-Meteo 免费 API(geocoding + forecast,无需 API key)
5
+ * 拿城市当前天气 + 未来 3 天预报,`details` 字段携带结构化数据供 `renderers/weather.ts`
6
+ * 消费(RFC-105 D6 渲染器型:`matcher.toolName='get_weather'` 接管该工具结果的终端展示,
7
+ * 见 otto-plugin.json 的 `contributes.renderers`)。
8
+ *
9
+ * 为什么不用 a2uiComponents 轴(终局 review endgame-2026-07-23 补充术语澄清——`a2uiComponents`
10
+ * 与下方 `contributes.a2uiRenderers`(见 extensions/plugin-deploy-kit 的 RFC-210 D5 用法)是
11
+ * 两条不同的贡献点,本节讨论的是前者):a2ui 组件由模型输出的 `a2ui` 内容块触发渲染,但当前
12
+ * 协议层(`@x-otto/interchange` message.ts)的 `AssistantMessage.content`/`ToolResultMessage.content`
13
+ * 均不含 `A2uiContent`——只有 `UserMessage.content` 支持,意味着工具结果无法产出 a2ui 块
14
+ * 触发渲染(无真实生产者)。`contributes.renderers` 轴(按 `matcher` 接管 `tool_result`
15
+ * 消息渲染)今天完整可用,且语义上更贴切"格式化某个工具结果的展示方式"这个场景。若未来
16
+ * 协议层扩展 `ToolResultMessage.content` 支持 `A2uiContent`,本渲染器可迁移为
17
+ * `contributes.a2uiRenderers`(对齐 plugin-deploy-kit 的 `get_deploy_status` 范式)。
18
+ */
19
+ import { definePlugin } from '@x-otto/plugin'
20
+ import { fetchCurrentWeatherAndForecast } from './src/open-meteo-client'
21
+ import { weatherToolParamsSchema, type WeatherToolParams } from './src/schema'
22
+
23
+ export default definePlugin(() => ({
24
+ tools: [
25
+ {
26
+ name: 'get_weather',
27
+ description:
28
+ 'Get the current weather and 3-day forecast for a city. Uses Open-Meteo (no API key required).',
29
+ parameters: weatherToolParamsSchema,
30
+ async execute(args: unknown) {
31
+ const { city } = args as WeatherToolParams
32
+ const data = await fetchCurrentWeatherAndForecast(city)
33
+ if (!data) {
34
+ return {
35
+ content: [{ type: 'text', text: `City not found: "${city}"` }],
36
+ isError: true,
37
+ }
38
+ }
39
+ return {
40
+ content: [
41
+ {
42
+ type: 'text',
43
+ text: `${data.city}: ${data.current.temperature}°C, ${data.current.description}`,
44
+ },
45
+ ],
46
+ // details 携带结构化数据,供 renderers/weather.ts 渲染函数消费(不经过文本解析)。
47
+ details: data,
48
+ }
49
+ },
50
+ },
51
+ ],
52
+ }))
@@ -0,0 +1,108 @@
1
+ /**
2
+ * open-meteo-client.ts —— Open-Meteo API 客户端(geocoding + forecast,免费、无需 API key)。
3
+ * 两次请求:① geocoding 把城市名解析为经纬度;② forecast 用经纬度拿当前天气 + 3 天预报。
4
+ * 网络/解析失败一律返回 `null`(fail-soft,由 plugin.ts 转成 `isError:true` 的 ToolResult,
5
+ * 不抛异常——工具执行层的既有约定,见 `@x-otto/interchange AgentTool.execute` 契约)。
6
+ */
7
+ import { describeWeatherCode } from './weather-codes'
8
+ import type { WeatherData } from './types'
9
+
10
+ const GEOCODING_URL = 'https://geocoding-api.open-meteo.com/v1/search'
11
+ const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'
12
+ const REQUEST_TIMEOUT_MS = 10_000
13
+
14
+ interface GeocodingResult {
15
+ latitude: number
16
+ longitude: number
17
+ name: string
18
+ country?: string
19
+ }
20
+
21
+ async function geocodeCity(city: string, signal: AbortSignal): Promise<GeocodingResult | null> {
22
+ const url = new URL(GEOCODING_URL)
23
+ url.searchParams.set('name', city)
24
+ url.searchParams.set('count', '1')
25
+ url.searchParams.set('language', 'en')
26
+ url.searchParams.set('format', 'json')
27
+
28
+ const res = await fetch(url, { signal })
29
+ if (!res.ok) return null
30
+ const data = (await res.json()) as { results?: GeocodingResult[] }
31
+ return data.results?.[0] ?? null
32
+ }
33
+
34
+ interface ForecastResponse {
35
+ current?: {
36
+ temperature_2m: number
37
+ weather_code: number
38
+ wind_speed_10m: number
39
+ relative_humidity_2m: number
40
+ }
41
+ daily?: {
42
+ time: string[]
43
+ temperature_2m_max: number[]
44
+ temperature_2m_min: number[]
45
+ weather_code: number[]
46
+ }
47
+ }
48
+
49
+ async function fetchForecast(
50
+ latitude: number,
51
+ longitude: number,
52
+ signal: AbortSignal,
53
+ ): Promise<ForecastResponse | null> {
54
+ const url = new URL(FORECAST_URL)
55
+ url.searchParams.set('latitude', String(latitude))
56
+ url.searchParams.set('longitude', String(longitude))
57
+ url.searchParams.set('current', 'temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m')
58
+ url.searchParams.set('daily', 'temperature_2m_max,temperature_2m_min,weather_code')
59
+ url.searchParams.set('timezone', 'auto')
60
+ url.searchParams.set('forecast_days', '3')
61
+
62
+ const res = await fetch(url, { signal })
63
+ if (!res.ok) return null
64
+ return (await res.json()) as ForecastResponse
65
+ }
66
+
67
+ /**
68
+ * 城市名 → 天气数据。城市未找到 / 网络失败 / 响应结构不符 一律返回 `null`(fail-soft)。
69
+ */
70
+ export async function fetchCurrentWeatherAndForecast(city: string): Promise<WeatherData | null> {
71
+ const controller = new AbortController()
72
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
73
+ try {
74
+ const location = await geocodeCity(city, controller.signal)
75
+ if (!location) return null
76
+
77
+ const forecast = await fetchForecast(location.latitude, location.longitude, controller.signal)
78
+ if (!forecast?.current || !forecast.daily) return null
79
+
80
+ const currentInfo = describeWeatherCode(forecast.current.weather_code)
81
+
82
+ return {
83
+ city: location.name,
84
+ country: location.country,
85
+ current: {
86
+ temperature: Math.round(forecast.current.temperature_2m * 10) / 10,
87
+ humidity: forecast.current.relative_humidity_2m,
88
+ windSpeed: forecast.current.wind_speed_10m,
89
+ description: currentInfo.description,
90
+ icon: currentInfo.icon,
91
+ },
92
+ forecast: forecast.daily.time.map((date, i) => {
93
+ const info = describeWeatherCode(forecast.daily!.weather_code[i] ?? -1)
94
+ return {
95
+ date,
96
+ tempMax: Math.round((forecast.daily!.temperature_2m_max[i] ?? 0) * 10) / 10,
97
+ tempMin: Math.round((forecast.daily!.temperature_2m_min[i] ?? 0) * 10) / 10,
98
+ description: info.description,
99
+ icon: info.icon,
100
+ }
101
+ }),
102
+ }
103
+ } catch {
104
+ return null
105
+ } finally {
106
+ clearTimeout(timeout)
107
+ }
108
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * schema.ts —— `get_weather` 工具参数 schema(`PluginTool.parameters` 只要求 `safeParse`
3
+ * 接口,zod schema 天然兼容,装载器不下沉具体协议类型)。
4
+ */
5
+ import { z } from 'zod'
6
+
7
+ export const weatherToolParamsSchema = z.object({
8
+ // max(200):真实城市名远短于此(已知最长地名约 85 字符),上限只为防御纵深——
9
+ // 拦截异常/失控模型输出的超长字符串直接拼进 geocoding 请求 URL(独立审核 R3 发现)。
10
+ city: z
11
+ .string()
12
+ .min(1)
13
+ .max(200)
14
+ .describe('City name to look up weather for, e.g. "Beijing" or "Tokyo"'),
15
+ })
16
+
17
+ export type WeatherToolParams = z.infer<typeof weatherToolParamsSchema>
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * types.ts —— get_weather 工具 `details` 字段的结构化数据形状(工具 execute 产出 →
3
+ * renderers/weather.ts 消费,两侧共享同一份类型,`entry` 编译产物是独立文件,靠此接口
4
+ * 保证字段契约一致,不依赖运行时校验)。
5
+ */
6
+ export interface DailyForecast {
7
+ /** ISO 日期(YYYY-MM-DD)。 */
8
+ date: string
9
+ tempMax: number
10
+ tempMin: number
11
+ description: string
12
+ icon: string
13
+ }
14
+
15
+ export interface WeatherData {
16
+ city: string
17
+ country?: string
18
+ current: {
19
+ temperature: number
20
+ humidity: number
21
+ windSpeed: number
22
+ description: string
23
+ icon: string
24
+ }
25
+ /** 未来 3 天预报(含当天),按日期升序。 */
26
+ forecast: DailyForecast[]
27
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * weather-codes.ts —— WMO weather interpretation codes → 人类可读描述 + emoji 图标。
3
+ * 数据来源:Open-Meteo API 文档(WMO Weather interpretation codes (WW))。
4
+ */
5
+ export interface WeatherCodeInfo {
6
+ description: string
7
+ icon: string
8
+ }
9
+
10
+ const WMO_CODE_TABLE: Record<number, WeatherCodeInfo> = {
11
+ 0: { description: 'Clear sky', icon: '☀️' },
12
+ 1: { description: 'Mainly clear', icon: '🌤️' },
13
+ 2: { description: 'Partly cloudy', icon: '⛅' },
14
+ 3: { description: 'Overcast', icon: '☁️' },
15
+ 45: { description: 'Fog', icon: '🌫️' },
16
+ 48: { description: 'Depositing rime fog', icon: '🌫️' },
17
+ 51: { description: 'Light drizzle', icon: '🌦️' },
18
+ 53: { description: 'Moderate drizzle', icon: '🌦️' },
19
+ 55: { description: 'Dense drizzle', icon: '🌧️' },
20
+ 56: { description: 'Light freezing drizzle', icon: '🌧️' },
21
+ 57: { description: 'Dense freezing drizzle', icon: '🌧️' },
22
+ 61: { description: 'Slight rain', icon: '🌧️' },
23
+ 63: { description: 'Moderate rain', icon: '🌧️' },
24
+ 65: { description: 'Heavy rain', icon: '🌧️' },
25
+ 66: { description: 'Light freezing rain', icon: '🌧️' },
26
+ 67: { description: 'Heavy freezing rain', icon: '🌧️' },
27
+ 71: { description: 'Slight snow fall', icon: '🌨️' },
28
+ 73: { description: 'Moderate snow fall', icon: '🌨️' },
29
+ 75: { description: 'Heavy snow fall', icon: '❄️' },
30
+ 77: { description: 'Snow grains', icon: '❄️' },
31
+ 80: { description: 'Slight rain showers', icon: '🌦️' },
32
+ 81: { description: 'Moderate rain showers', icon: '🌧️' },
33
+ 82: { description: 'Violent rain showers', icon: '⛈️' },
34
+ 85: { description: 'Slight snow showers', icon: '🌨️' },
35
+ 86: { description: 'Heavy snow showers', icon: '❄️' },
36
+ 95: { description: 'Thunderstorm', icon: '⛈️' },
37
+ 96: { description: 'Thunderstorm with slight hail', icon: '⛈️' },
38
+ 99: { description: 'Thunderstorm with heavy hail', icon: '⛈️' },
39
+ }
40
+
41
+ const UNKNOWN_CODE_INFO: WeatherCodeInfo = { description: 'Unknown', icon: '❔' }
42
+
43
+ export function describeWeatherCode(code: number): WeatherCodeInfo {
44
+ return WMO_CODE_TABLE[code] ?? UNKNOWN_CODE_INFO
45
+ }