@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/README.md +34 -0
- package/otto-plugin.json +17 -0
- package/package.json +27 -0
- package/plugin-dist/meta.json +1 -0
- package/plugin-dist/plugin.cjs +52383 -0
- package/plugin-dist/plugin.js +13935 -0
- package/plugin.ts +52 -0
- package/src/open-meteo-client.ts +108 -0
- package/src/schema.ts +17 -0
- package/src/types.ts +27 -0
- package/src/weather-codes.ts +45 -0
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
|
+
}
|