核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
263 lines
9.4 KiB
JavaScript
263 lines
9.4 KiB
JavaScript
/**
|
||
* geo-weather.js
|
||
*
|
||
* 启动时自动采集位置坐标 + 实时天气,落盘缓存,注入 system prompt。
|
||
*
|
||
* 数据分两层:
|
||
* location — 坐标 + 地址详情,IP 未变时最长 7 天复用缓存
|
||
* weather — 当前天气 + 今明两天,每次启动刷新
|
||
*
|
||
* 数据来源(全部免费,无需 API Key):
|
||
* ip-api.com → 公网 IP 反查坐标 / 城市 / 时区
|
||
* nominatim (OSM) → 坐标反解详细地址(区级)
|
||
* wttr.in → 天气(当前 + 今明两天)
|
||
*
|
||
* 对外接口:
|
||
* collectGeoWeather() → 启动时调用一次,async
|
||
* getGeoWeatherBlock() → 返回注入 prompt 的纯文本块,同步
|
||
*/
|
||
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
import { paths } from './paths.js'
|
||
|
||
const GEO_WEATHER_FILE = path.join(paths.dataDir, 'geo-weather.json')
|
||
const GEO_WEATHER_VERSION = 1
|
||
const LOCATION_REFRESH_MS = 7 * 24 * 60 * 60 * 1000 // 位置信息兜底 7d 刷新一次
|
||
|
||
let _cached = null
|
||
|
||
// ─── 工具函数 ──────────────────────────────────────────────────────────────────
|
||
|
||
function safe(fn, fallback = null) {
|
||
try { return fn() } catch { return fallback }
|
||
}
|
||
|
||
async function fetchJSON(url, options = {}, timeoutMs = 8000) {
|
||
try {
|
||
const ctrl = new AbortController()
|
||
const t = setTimeout(() => ctrl.abort(), timeoutMs)
|
||
const res = await fetch(url, { signal: ctrl.signal, ...options })
|
||
clearTimeout(t)
|
||
if (!res.ok) return null
|
||
return await res.json()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// ─── 位置采集 ─────────────────────────────────────────────────────────────────
|
||
|
||
async function fetchLocation() {
|
||
// Step 1:IP 反查坐标(同时拿到城市 / 时区 / ISP)
|
||
const ipData = await fetchJSON(
|
||
'http://ip-api.com/json/?fields=status,query,city,regionName,country,countryCode,lat,lon,timezone,isp'
|
||
)
|
||
if (!ipData || ipData.status !== 'success') {
|
||
console.warn('[geo-weather] ip-api.com 定位失败')
|
||
return null
|
||
}
|
||
|
||
const { lat, lon, city, regionName, country, countryCode, timezone, isp, query: ip } = ipData
|
||
|
||
// Step 2:Nominatim 反解精细地址(区 / 街道级)
|
||
// User-Agent 是 Nominatim 免费使用协议要求
|
||
const nominatim = await fetchJSON(
|
||
'https://nominatim.openstreetmap.org/reverse?lat=' + lat + '&lon=' + lon + '&format=json&accept-language=zh',
|
||
{ headers: { 'User-Agent': 'BaiLongma/2.0 (personal-assistant; contact: user@example.com)' } },
|
||
10000
|
||
)
|
||
|
||
const addr = nominatim?.address || {}
|
||
|
||
return {
|
||
lat: Number(lat),
|
||
lon: Number(lon),
|
||
ip,
|
||
isp: isp || null,
|
||
city: addr.city || addr.town || addr.village || city,
|
||
district: addr.suburb || addr.district || addr.county || null,
|
||
region: addr.state || regionName,
|
||
country: addr.country || country,
|
||
country_code: (addr.country_code || countryCode || '').toUpperCase(),
|
||
timezone: timezone || null,
|
||
display_name: nominatim?.display_name || (city + ', ' + regionName + ', ' + country),
|
||
collected_at: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
// ─── 天气采集 ─────────────────────────────────────────────────────────────────
|
||
|
||
function parseWttrDay(day) {
|
||
if (!day) return null
|
||
// hourly 索引 4 ≈ 12:00,作为当天代表天气描述
|
||
const noon = day.hourly?.[4]
|
||
const condition = noon?.lang_zh?.[0]?.value || noon?.weatherDesc?.[0]?.value
|
||
|| day.hourly?.[0]?.lang_zh?.[0]?.value || ''
|
||
return {
|
||
date: day.date || '',
|
||
high: Number(day.maxtempC),
|
||
low: Number(day.mintempC),
|
||
condition,
|
||
sunrise: day.astronomy?.[0]?.sunrise || '',
|
||
sunset: day.astronomy?.[0]?.sunset || '',
|
||
}
|
||
}
|
||
|
||
async function fetchWeather(lat, lon, city) {
|
||
// 优先用坐标精确定位,坐标缺失时降级到城市名
|
||
const query = (lat != null && lon != null)
|
||
? (lat + ',' + lon)
|
||
: encodeURIComponent(city || 'Beijing')
|
||
|
||
const data = await fetchJSON(
|
||
'https://wttr.in/' + query + '?format=j1&lang=zh',
|
||
{},
|
||
12000
|
||
)
|
||
if (!data) {
|
||
console.warn('[geo-weather] wttr.in 天气获取失败')
|
||
return null
|
||
}
|
||
|
||
const cur = data.current_condition?.[0]
|
||
if (!cur) return null
|
||
|
||
return {
|
||
temp: Number(cur.temp_C),
|
||
feels_like: Number(cur.FeelsLikeC),
|
||
humidity: Number(cur.humidity),
|
||
condition: cur.lang_zh?.[0]?.value || cur.weatherDesc?.[0]?.value || '',
|
||
wind_kmh: Number(cur.windspeedKmph),
|
||
wind_dir: cur.winddir16Point || '',
|
||
uv_index: Number(cur.uvIndex),
|
||
visibility: Number(cur.visibility),
|
||
today: parseWttrDay(data.weather?.[0]),
|
||
tomorrow: parseWttrDay(data.weather?.[1]),
|
||
fetched_at: new Date().toISOString(),
|
||
}
|
||
}
|
||
|
||
// ─── 核心:采集 + 落盘 ────────────────────────────────────────────────────────
|
||
|
||
export async function collectGeoWeather() {
|
||
// 读取已有缓存
|
||
let stored = null
|
||
if (fs.existsSync(GEO_WEATHER_FILE)) {
|
||
stored = safe(() => JSON.parse(fs.readFileSync(GEO_WEATHER_FILE, 'utf8')))
|
||
}
|
||
|
||
let location = (stored?.version === GEO_WEATHER_VERSION) ? (stored.location || null) : null
|
||
|
||
// 先轻量查一次当前公网 IP(复用 ip-api 调用,不做完整定位)
|
||
const ipCheck = await fetchJSON(
|
||
'http://ip-api.com/json/?fields=status,query',
|
||
{},
|
||
5000
|
||
)
|
||
const currentIP = (ipCheck?.status === 'success') ? ipCheck.query : null
|
||
|
||
// 触发重查的两个条件:IP 变更(网络切换 / 出差),或缓存超过 7 天
|
||
const cachedIP = location?.ip || null
|
||
const locationAge = location?.collected_at
|
||
? Date.now() - new Date(location.collected_at).getTime()
|
||
: Infinity
|
||
const ipChanged = currentIP && cachedIP && currentIP !== cachedIP
|
||
const tooOld = locationAge > LOCATION_REFRESH_MS
|
||
|
||
if (ipChanged || tooOld || !location) {
|
||
const reason = !location ? '首次采集' : ipChanged ? ('IP 变更 ' + cachedIP + ' → ' + currentIP) : '缓存超 7 天'
|
||
console.log('[geo-weather] 刷新位置信息(' + reason + ')...')
|
||
const fresh = await fetchLocation()
|
||
if (fresh) {
|
||
location = fresh
|
||
} else if (location) {
|
||
console.warn('[geo-weather] 位置刷新失败,沿用缓存')
|
||
}
|
||
} else {
|
||
console.log('[geo-weather] 位置缓存有效(IP 未变),跳过重新定位')
|
||
}
|
||
|
||
// 天气:每次启动都刷新
|
||
let weather = null
|
||
if (location) {
|
||
console.log('[geo-weather] 获取天气数据...')
|
||
weather = await fetchWeather(location.lat, location.lon, location.city)
|
||
}
|
||
|
||
const result = {
|
||
version: GEO_WEATHER_VERSION,
|
||
location: location || null,
|
||
weather: weather || null,
|
||
}
|
||
|
||
safe(() => fs.writeFileSync(GEO_WEATHER_FILE, JSON.stringify(result, null, 2), 'utf8'))
|
||
_cached = result
|
||
|
||
const locLabel = location ? (location.city + ' (' + location.lat + ', ' + location.lon + ')') : '未知'
|
||
const wxLabel = weather ? (weather.condition + ' ' + weather.temp + '°C') : '获取失败'
|
||
console.log('[geo-weather] 完成 — 位置:', locLabel, '| 天气:', wxLabel)
|
||
return result
|
||
}
|
||
|
||
// ─── 对外接口 ─────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 返回注入 system prompt 的纯文本块。
|
||
* 必须在 collectGeoWeather() 完成后调用。
|
||
*/
|
||
export function getGeoWeatherBlock() {
|
||
if (!_cached) return ''
|
||
const parts = []
|
||
|
||
// —— 位置块 ——
|
||
const loc = _cached.location
|
||
if (loc) {
|
||
const lines = ['## Location & Coordinates']
|
||
lines.push('Coordinates: ' + loc.lat + ', ' + loc.lon)
|
||
|
||
const place = [loc.district, loc.city, loc.region, loc.country].filter(Boolean).join(', ')
|
||
if (place) lines.push('Address: ' + place)
|
||
if (loc.country_code) lines.push('Country Code: ' + loc.country_code)
|
||
if (loc.timezone) lines.push('Timezone: ' + loc.timezone)
|
||
if (loc.ip) lines.push('Public IP: ' + loc.ip + (loc.isp ? ' · ' + loc.isp : ''))
|
||
if (loc.display_name) lines.push('Full Address: ' + loc.display_name)
|
||
|
||
parts.push(lines.join('\n'))
|
||
}
|
||
|
||
// —— 天气块 ——
|
||
const w = _cached.weather
|
||
if (w) {
|
||
const lines = ['## Current Weather']
|
||
lines.push(
|
||
'Now: ' + w.condition +
|
||
' · ' + w.temp + '°C (feels like ' + w.feels_like + '°C)' +
|
||
' · Humidity ' + w.humidity + '%'
|
||
)
|
||
lines.push(
|
||
'Wind: ' + w.wind_kmh + ' km/h ' + w.wind_dir +
|
||
' · UV Index ' + w.uv_index +
|
||
' · Visibility ' + w.visibility + ' km'
|
||
)
|
||
|
||
if (w.today) {
|
||
let todayLine = 'Today (' + w.today.date + '): ' + w.today.condition +
|
||
' · High ' + w.today.high + '°C / Low ' + w.today.low + '°C'
|
||
if (w.today.sunrise) todayLine += ' · Sunrise ' + w.today.sunrise + ' / Sunset ' + w.today.sunset
|
||
lines.push(todayLine)
|
||
}
|
||
|
||
if (w.tomorrow) {
|
||
lines.push(
|
||
'Tomorrow (' + w.tomorrow.date + '): ' + w.tomorrow.condition +
|
||
' · High ' + w.tomorrow.high + '°C / Low ' + w.tomorrow.low + '°C'
|
||
)
|
||
}
|
||
|
||
parts.push(lines.join('\n'))
|
||
}
|
||
|
||
return parts.join('\n\n')
|
||
}
|