Files
homeagent-sdk/example/weather/plugin.go
root aee63a4f98 example: 演示插件 onRemove 清理补齐(memo/rss/weather)
- memo: 卸载时删除 memos.json 数据文件
- rss: 卸载时清理 .homeagent/rss 订阅数据目录
- weather: 卸载时清理 .homeagent/weather 缓存目录
- 与 calendar(events.json)一致:仅删除触发、重载不触发;
  files(filesDir 为用户配置的访问根目录)、bili/qq(用户下载资产)、
  ocr(临时目录函数内自清理)按语义不加入删除回调
2026-08-02 13:37:59 +08:00

438 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
client *http.Client
defaultLoc string
dataDir string
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.client = &http.Client{Timeout: 15 * time.Second}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "default_location", Default: "", Type: "string",
DisplayName: "Default Location", Description: "Default city name for weather queries, e.g. Beijing",
Category: "weather",
})
if v, _ := s.Settings().Get("default_location"); v != nil {
if vs, ok := v.(string); ok {
p.defaultLoc = vs
}
}
dataHome := os.Getenv("HOME")
if dataHome == "" {
dataHome = "/tmp"
}
p.dataDir = filepath.Join(dataHome, ".homeagent", "weather")
os.MkdirAll(p.dataDir, 0755)
// 卸载(删除)时清理天气缓存目录;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
tp := p.name + "_"
s.RegisterTool(tp+"current", sdk.ToolDef{
Name: tp + "current", Description: "Get current weather for a city",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name (e.g. Beijing, Shanghai, London). Uses default if omitted."},
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
},
},
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
NoMemory: true,
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
Cleaner: func(output string) string {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "🌤") {
return line
}
}
return output
},
}, p.handleCurrent)
s.RegisterTool(tp+"forecast", sdk.ToolDef{
Name: tp + "forecast", Description: "Get weather forecast for next several days",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name. Uses default if omitted."},
"days": map[string]interface{}{"type": "integer", "description": "Number of days (1-7), default 3"},
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
},
},
NoMemory: true,
}, p.handleForecast)
s.RegisterTool(tp+"set_location", sdk.ToolDef{
Name: tp + "set_location", Description: "Set default weather location",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"location": map[string]interface{}{"type": "string", "description": "City name to set as default"},
},
"required": []string{"location"},
},
NoMemory: true,
}, p.handleSetLocation)
// 阶段钩子own_tools 作用域——仅在本插件的工具被调用时触发
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolResults) > 0 {
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
}
return nil
}, sdk.StageScopeOwnTools)
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
NoMemory: true,
}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
}); err != nil {
return err
}
// 输入通道接收天气订阅请求NoMemory: 通道输入不参与记忆计算)
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
return err
}
fmt.Printf("[%s] started\n", p.name)
return nil
}
func (p *Plugin) Stop() error {
fmt.Printf("[%s] stopped\n", p.name)
return nil
}
type wttrResp struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
FeelsLikeC string `json:"FeelsLikeC"`
Humidity string `json:"humidity"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Pressure string `json:"pressure"`
Visibility string `json:"visibility"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
LocalObsDateTime string `json:"localObsDateTime"`
} `json:"current_condition"`
NearestArea []struct {
AreaName []struct {
Value string `json:"value"`
} `json:"areaName"`
Country []struct {
Value string `json:"value"`
} `json:"country"`
Region []struct {
Value string `json:"value"`
} `json:"region"`
} `json:"nearest_area"`
Weather []wttrDay `json:"weather"`
}
type wttrDay struct {
Date string `json:"date"`
Astronomy []struct {
Sunrise string `json:"sunrise"`
Sunset string `json:"sunset"`
} `json:"astronomy"`
MaxtempC string `json:"maxtempC"`
MintempC string `json:"mintempC"`
Hourly []struct {
TempC string `json:"tempC"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
WindspeedKmph string `json:"windspeedKmph"`
Winddir16Point string `json:"winddir16Point"`
Humidity string `json:"humidity"`
FeelsLikeC string `json:"FeelsLikeC"`
PrecipMM string `json:"precipMM"`
Visibility string `json:"visibility"`
} `json:"hourly"`
}
func (p *Plugin) getLoc(args map[string]interface{}) string {
if v, ok := args["location"].(string); ok && v != "" {
return v
}
return p.defaultLoc
}
func (p *Plugin) getUnits(args map[string]interface{}) string {
if v, ok := args["units"].(string); ok && (v == "imperial" || v == "metric") {
return v
}
return "metric"
}
func (p *Plugin) fetchWttr(location string) (*wttrResp, error) {
url := fmt.Sprintf("https://wttr.in/%s?format=j1", strings.ReplaceAll(location, " ", "%20"))
resp, err := p.client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var data wttrResp
if err := json.Unmarshal(body, &data); err != nil {
return nil, err
}
if len(data.CurrentCondition) == 0 {
return nil, fmt.Errorf("no weather data for: %s", location)
}
return &data, nil
}
func (p *Plugin) displayName(data *wttrResp) string {
if len(data.NearestArea) == 0 {
return "Unknown"
}
area := data.NearestArea[0]
name := ""
if len(area.AreaName) > 0 {
name = area.AreaName[0].Value
}
region := ""
if len(area.Region) > 0 {
region = area.Region[0].Value
}
country := ""
if len(area.Country) > 0 {
country = area.Country[0].Value
}
var parts []string
if name != "" {
parts = append(parts, name)
}
if region != "" && region != name {
parts = append(parts, region)
}
if country != "" {
parts = append(parts, country)
}
return strings.Join(parts, ", ")
}
func convertCtoF(c string) string {
if v, err := strconv.ParseFloat(c, 64); err == nil {
return fmt.Sprintf("%.0f", v*9/5+32)
}
return c
}
func (p *Plugin) handleCurrent(args map[string]interface{}) (interface{}, error) {
location := p.getLoc(args)
if location == "" {
return map[string]interface{}{"isError": true, "content": "No location specified. Provide a city name or set default_location."}, nil
}
units := p.getUnits(args)
data, err := p.fetchWttr(location)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Weather request failed: " + err.Error()}, nil
}
cc := data.CurrentCondition[0]
place := p.displayName(data)
desc := ""
if len(cc.WeatherDesc) > 0 {
desc = cc.WeatherDesc[0].Value
}
unitStr := "°C"
windUnit := "km/h"
tempStr := cc.TempC
feelsStr := cc.FeelsLikeC
if units == "imperial" {
unitStr = "°F"
windUnit = "mph"
tempStr = convertCtoF(tempStr)
feelsStr = convertCtoF(feelsStr)
}
obsTime := cc.LocalObsDateTime
if len(obsTime) > 16 {
obsTime = obsTime[:16]
}
result := fmt.Sprintf("🌤 %s — %s\n🌡 %s%s (体感 %s%s)\n💧 湿度 %s%% | 💨 风速 %s %s %s\n🕐 %s",
place, desc,
tempStr, unitStr, feelsStr, unitStr,
cc.Humidity, cc.WindspeedKmph, windUnit, cc.Winddir16Point,
obsTime)
// 文本记忆每次查询写入一条历史记录role=tool 便于追溯)
if p.sdk.TextMemory() != nil {
_ = p.sdk.TextMemory().Append(sdk.TextEvent{
Role: "tool",
Content: fmt.Sprintf("weather %s: %s", place, desc),
Channel: p.name,
})
}
return map[string]interface{}{
"content": result,
"location": place,
"temp": cc.TempC,
"feels_like": cc.FeelsLikeC,
"humidity": cc.Humidity,
"wind_speed": cc.WindspeedKmph,
"weather": desc,
"observed": obsTime,
}, nil
}
func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error) {
location := p.getLoc(args)
if location == "" {
return map[string]interface{}{"isError": true, "content": "No location specified."}, nil
}
days := 3
if v, ok := args["days"].(float64); ok {
d := int(v)
if d >= 1 && d <= 7 {
days = d
}
}
units := p.getUnits(args)
data, err := p.fetchWttr(location)
if err != nil {
return map[string]interface{}{"isError": true, "content": "Forecast request failed: " + err.Error()}, nil
}
place := p.displayName(data)
unitStr := "°C"
if units == "imperial" {
unitStr = "°F"
}
dayCount := days
if dayCount > len(data.Weather) {
dayCount = len(data.Weather)
}
daysData := data.Weather[:dayCount]
var lines []string
lines = append(lines, fmt.Sprintf("📅 %d日天气预报 — %s", days, place))
for _, day := range daysData {
t, err := time.Parse("2006-01-02", day.Date)
if err != nil {
continue
}
weekday := t.Weekday().String()[:3]
maxT := day.MaxtempC
minT := day.MintempC
desc := ""
precip := ""
if len(day.Hourly) > 0 {
mid := len(day.Hourly) / 2
if len(day.Hourly[mid].WeatherDesc) > 0 {
desc = day.Hourly[mid].WeatherDesc[0].Value
}
totalPrecip := 0.0
for _, h := range day.Hourly {
if pv, err := strconv.ParseFloat(h.PrecipMM, 64); err == nil {
totalPrecip += pv
}
}
if totalPrecip > 0 {
precip = fmt.Sprintf(" 🌧%.1fmm", totalPrecip)
}
}
if units == "imperial" {
maxT = convertCtoF(maxT)
minT = convertCtoF(minT)
}
sunrise, sunset := "", ""
if len(day.Astronomy) > 0 {
sunrise = day.Astronomy[0].Sunrise
sunset = day.Astronomy[0].Sunset
}
line := fmt.Sprintf(" %s %s/%s — %s~%s%s %s", weekday, day.Date[5:], day.Date[8:], minT, maxT, unitStr, desc)
if precip != "" {
line += precip
}
if sunrise != "" && sunset != "" {
line += fmt.Sprintf(" 🌅%s 🌇%s", sunrise, sunset)
}
lines = append(lines, line)
}
cc := data.CurrentCondition[0]
nowDesc := ""
if len(cc.WeatherDesc) > 0 {
nowDesc = cc.WeatherDesc[0].Value
}
lines = append(lines, fmt.Sprintf("\n当前%s %s°C", nowDesc, cc.TempC))
return map[string]interface{}{
"content": strings.Join(lines, "\n"),
"location": place,
}, nil
}
func (p *Plugin) handleSetLocation(args map[string]interface{}) (interface{}, error) {
loc, _ := args["location"].(string)
if loc == "" {
return map[string]interface{}{"isError": true, "content": "Location is required"}, nil
}
p.sdk.Settings().Set("default_location", loc)
p.defaultLoc = loc
return map[string]interface{}{"content": fmt.Sprintf("Default location set to: %s", loc)}, nil
}
// cleanupData 卸载时清理天气缓存目录
func (p *Plugin) cleanupData() {
if p.dataDir != "" {
os.RemoveAll(p.dataDir)
}
}