From 071e36110e105bcbdff410f551cc52af3459e33c Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 26 Jul 2026 18:43:20 +0900 Subject: [PATCH] =?UTF-8?q?v4.3.19-7:=20NASA=20EONET/Worldview=20=EC=9C=84?= =?UTF-8?q?=EC=84=B1=EC=82=AC=EC=A7=84=20=EA=B8=B0=EB=8A=A5=20+=20weather-?= =?UTF-8?q?app=20=EB=A9=80=ED=8B=B0=ED=83=AD=20=EB=B7=B0=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eonet_events/satellite_snapshot 도구 추가 (키 불필요 공개 API) - weather-app.html: 위성사진 채팅 이미지 렌더링, 지도/사진 멀티탭 센터패널, 확대/축소·드래그팬, localStorage+히스토리 이중 복원으로 새로고침 시 탭 유지(하드리셋으로 localStorage가 지워져도 대화 이력에서 재구성) --- src/gateway/chat/build-tools.ts | 33 ++++++ src/gateway/chat/handle-chat.ts | 4 + src/tools/registry.ts | 4 + src/tools/satellite.ts | 163 ++++++++++++++++++++++++++++ web-ui/html/weather-app.html | 185 ++++++++++++++++++++++++++++++-- 5 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 src/tools/satellite.ts diff --git a/src/gateway/chat/build-tools.ts b/src/gateway/chat/build-tools.ts index f0f7c06..e69b0ad 100644 --- a/src/gateway/chat/build-tools.ts +++ b/src/gateway/chat/build-tools.ts @@ -994,6 +994,39 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) { }, }, }, + { + type: 'function', + function: { + name: 'eonet_events', + description: 'NASA EONET에서 현재 진행 중이거나 최근 종료된 전 세계 자연재해/이벤트(산불, 태풍, 화산, 지진, 홍수, 산사태, 가뭄, 폭설 등 13종) 목록을 가져온다. 각 이벤트는 좌표(coordinates)가 포함되어 있어 satellite_snapshot 호출 시 lat/lon으로 바로 쓸 수 있다.', + parameters: { + type: 'object', + properties: { + category: { type: 'string', description: 'EONET category id, e.g. wildfires, severeStorms, volcanoes, earthquakes, floods, landslides, drought, snow' }, + status: { type: 'string', enum: ['open', 'closed'], description: 'open (default) or closed' }, + days: { type: 'number', description: 'Only events from the last N days (default 20, max 60)' }, + limit: { type: 'number', description: 'Max events to return (default 15, max 50)' }, + }, + }, + }, + }, + { + type: 'function', + function: { + name: 'satellite_snapshot', + description: 'NASA Worldview Snapshots로 특정 위경도 주변의 실제 위성사진을 받아 워크스페이스에 저장하고 채팅에 표시한다. eonet_events가 반환한 좌표를 그대로 쓸 수 있다. 오늘 날짜 이미지는 아직 처리 전일 수 있으니 date를 생략하면 자동으로 어제 날짜를 쓴다.', + parameters: { + type: 'object', required: ['lat', 'lon'], + properties: { + lat: { type: 'number', description: 'Center latitude' }, + lon: { type: 'number', description: 'Center longitude' }, + radius_deg: { type: 'number', description: 'Half-width of the view in degrees (default 3)' }, + date: { type: 'string', description: 'YYYY-MM-DD, UTC. Defaults to yesterday.' }, + layer: { type: 'string', enum: ['true_color', 'thermal', 'night'], description: 'Imagery preset (default true_color)' }, + }, + }, + }, + }, { type: 'function', function: { diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index c3a7199..926ab44 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -244,6 +244,9 @@ async function handleChat( // biggest remaining pair) only make sense once a PDF is actually referenced. const hasPdfAttachment = /\.pdf\b|pdf\s*(파일|읽어|추출)/i.test(message); const hasScheduleKeyword = /예약|스케줄|반복|매일|매주|매월|정기적|주기적|cron|schedule|recurring/i.test(message); + // eonet_events/satellite_snapshot — casual "what's happening on Earth" feature, + // not relevant to unrelated turns. + const hasSatelliteKeyword = /위성|자연재해|산불|태풍|화산|지진|산사태|가뭄|황사|연무|해빙|satellite|wildfire|volcano|eonet|natural\s*disaster/i.test(message); const meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace); const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace); const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace); @@ -322,6 +325,7 @@ async function handleChat( if (name === 'news_search' && !hasNewsKeyword) return false; if ((name === 'pdf_read' || name === 'pdf_extract_images') && !hasPdfAttachment) return false; if (name === 'schedule_job' && !hasScheduleKeyword) return false; + if ((name === 'eonet_events' || name === 'satellite_snapshot') && !hasSatelliteKeyword) return false; if (name === 'run_command' && !hasRunCommandKeyword) return false; return true; }; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index fbe8afb..f3f998b 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -22,6 +22,7 @@ import { audioTranscribeTool } from './audio-transcribe.js'; import { pythonEvalTool } from './python.js'; import { sqliteTool } from './sqlite.js'; import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js'; +import { eonetEventsTool, satelliteSnapshotTool } from './satellite.js'; import { newsSearchTool } from './news.js'; import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js'; import { kakaoSendTool } from './kakao.js'; @@ -223,6 +224,9 @@ class ToolRegistry { this.registerSafe(weatherCdsTool); this.registerSafe(weatherCmip6Tool); this.registerSafe(weatherMapScreenshotTool); + // NASA EONET (natural event tracker) + Worldview Snapshots (satellite imagery) — both public, no API key + this.registerSafe(eonetEventsTool); + this.registerSafe(satelliteSnapshotTool); // News (NewsData.io — real headlines with real publish dates, for news queries) this.registerSafe(newsSearchTool); // Email tools (IMAP + SMTP) diff --git a/src/tools/satellite.ts b/src/tools/satellite.ts new file mode 100644 index 0000000..ce45ae5 --- /dev/null +++ b/src/tools/satellite.ts @@ -0,0 +1,163 @@ +import { ToolResult } from '../types.js'; +import { getWorkspacePath } from '../config/paths.js'; +import { buildImageMarkdown } from './image.js'; +import path from 'path'; +import fs from 'fs'; +import https from 'https'; + +// Both NASA EONET (event metadata) and Worldview Snapshots (satellite imagery) +// are fully public — no API key, no registration, no NASA Earthdata login. +// Verified 2026-07-26. + +function httpsGetJson(url: string, timeoutMs = 20_000): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, { timeout: timeoutMs }, (res) => { + let raw = ''; + res.on('data', (c: Buffer) => { raw += c.toString(); }); + res.on('end', () => { + try { resolve(JSON.parse(raw)); } + catch { reject(new Error(`JSON parse error: ${raw.slice(0, 150)}`)); } + }); + }); + req.on('timeout', () => { req.destroy(); reject(new Error(`요청 타임아웃 (${timeoutMs / 1000}초)`)); }); + req.on('error', reject); + }); +} + +function httpsGetBuffer(url: string, timeoutMs = 60_000): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, { timeout: timeoutMs }, (res) => { + if ((res.statusCode || 0) >= 400) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks))); + }); + req.on('timeout', () => { req.destroy(); reject(new Error(`요청 타임아웃 (${timeoutMs / 1000}초)`)); }); + req.on('error', reject); + }); +} + +// ── eonet_events ────────────────────────────────────────────────────────────── + +const EONET_CATEGORIES: Record = { + wildfires: '산불', severeStorms: '폭풍/태풍', volcanoes: '화산', + seaLakeIce: '해빙/호수얼음', drought: '가뭄', dustHaze: '황사/연무', + earthquakes: '지진', floods: '홍수', landslides: '산사태', + manmade: '인위적 사고', snow: '폭설', tempExtremes: '이상고온/한파', waterColor: '수질이상', +}; + +export const eonetEventsTool = { + name: 'eonet_events', + description: 'NASA EONET에서 현재 진행 중이거나 최근 종료된 전 세계 자연재해/이벤트(산불, 태풍, 화산, 지진, 홍수, 산사태, 가뭄, 폭설 등 13종) 목록을 가져온다. 각 이벤트는 좌표(coordinates)가 포함되어 있어 satellite_snapshot 호출 시 lat/lon으로 바로 쓸 수 있다.', + schema: { + category: '필터할 카테고리(영문 코드, 예: wildfires, severeStorms). 생략하면 전체.', + status: '"open"(진행중, 기본값) 또는 "closed"(종료됨)', + days: '최근 며칠 이내 이벤트만 볼지 (기본 20, 최대 60)', + limit: '최대 반환 개수 (기본 15, 최대 50)', + }, + jsonSchema: { + type: 'object', + properties: { + category: { type: 'string', description: 'EONET category id, e.g. wildfires, severeStorms, volcanoes, earthquakes, floods, landslides, drought, snow' }, + status: { type: 'string', enum: ['open', 'closed'], description: 'open (default) or closed' }, + days: { type: 'number', description: 'Only events from the last N days (default 20, max 60)' }, + limit: { type: 'number', description: 'Max events to return (default 15, max 50)' }, + }, + additionalProperties: false, + }, + execute: async (args: any): Promise => { + const days = Math.min(Math.max(Number(args?.days) || 20, 1), 60); + const limit = Math.min(Math.max(Number(args?.limit) || 15, 1), 50); + const status = args?.status === 'closed' ? 'closed' : 'open'; + const params = new URLSearchParams({ status, days: String(days), limit: String(limit) }); + if (args?.category) params.set('category', String(args.category)); + try { + const data = await httpsGetJson(`https://eonet.gsfc.nasa.gov/api/v3/events?${params.toString()}`); + const events = Array.isArray(data?.events) ? data.events : []; + if (!events.length) return { success: true, stdout: '조건에 맞는 이벤트가 없습니다.' }; + const lines = events.map((e: any) => { + const cat = e.categories?.[0]?.id || ''; + const catLabel = EONET_CATEGORIES[cat] || cat; + const geom = Array.isArray(e.geometry) ? e.geometry[e.geometry.length - 1] : null; + const coords = geom?.coordinates; + const lon = Array.isArray(coords) ? coords[0] : (Array.isArray(coords?.[0]) ? coords[0][0]?.[0] : undefined); + const lat = Array.isArray(coords) ? coords[1] : (Array.isArray(coords?.[0]) ? coords[0][0]?.[1] : undefined); + const date = geom?.date ? String(geom.date).slice(0, 10) : ''; + const coordStr = (typeof lat === 'number' && typeof lon === 'number') ? `lat=${lat.toFixed(2)}, lon=${lon.toFixed(2)}` : '좌표없음(폴리곤)'; + return `- [${catLabel}] ${e.title} — ${date} — ${coordStr} (id: ${e.id})`; + }); + return { success: true, stdout: `${events.length}개 이벤트:\n${lines.join('\n')}` }; + } catch (err: any) { + return { success: false, error: `EONET 조회 실패: ${err.message}` }; + } + }, +}; + +// ── satellite_snapshot ──────────────────────────────────────────────────────── + +const SNAPSHOT_LAYERS: Record = { + true_color: 'MODIS_Terra_CorrectedReflectance_TrueColor,Coastlines', + thermal: 'MODIS_Terra_CorrectedReflectance_TrueColor,MODIS_Terra_Thermal_Anomalies_All,Coastlines', + night: 'VIIRS_SNPP_DayNightBand_ENCC,Coastlines', +}; + +export const satelliteSnapshotTool = { + name: 'satellite_snapshot', + description: 'NASA Worldview Snapshots로 특정 위경도 주변의 실제 위성사진을 받아 워크스페이스에 저장하고 채팅에 표시한다. eonet_events가 반환한 좌표를 그대로 쓸 수 있다. 오늘 날짜 이미지는 아직 처리 전일 수 있으니 date를 생략하면 자동으로 어제 날짜를 쓴다.', + schema: { + lat: '중심 위도 (필수)', + lon: '중심 경도 (필수)', + radius_deg: '중심에서 각 방향으로 몇 도 범위를 보여줄지 (기본 3, 값이 작을수록 확대)', + date: 'YYYY-MM-DD (생략시 어제 날짜, UTC 기준)', + layer: '"true_color"(기본, 실제 색상), "thermal"(화재/열이상 표시), "night"(야간 조명)', + }, + jsonSchema: { + type: 'object', + required: ['lat', 'lon'], + properties: { + lat: { type: 'number', description: 'Center latitude' }, + lon: { type: 'number', description: 'Center longitude' }, + radius_deg: { type: 'number', description: 'Half-width of the view in degrees (default 3)' }, + date: { type: 'string', description: 'YYYY-MM-DD, UTC. Defaults to yesterday.' }, + layer: { type: 'string', enum: ['true_color', 'thermal', 'night'], description: 'Imagery preset (default true_color)' }, + }, + additionalProperties: false, + }, + execute: async (args: any): Promise => { + const lat = Number(args?.lat); + const lon = Number(args?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { success: false, error: 'lat/lon이 필요합니다' }; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return { success: false, error: 'lat/lon 범위가 올바르지 않습니다' }; + const radius = Math.min(Math.max(Number(args?.radius_deg) || 3, 0.5), 20); + const layerKey = SNAPSHOT_LAYERS[String(args?.layer)] ? String(args.layer) : 'true_color'; + const layers = SNAPSHOT_LAYERS[layerKey]; + let date = String(args?.date || '').trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000); + date = yesterday.toISOString().slice(0, 10); + } + const bbox = [ + Math.max(lat - radius, -90), Math.max(lon - radius, -180), + Math.min(lat + radius, 90), Math.min(lon + radius, 180), + ].join(','); + const url = `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=${date}&BBOX=${bbox}&CRS=EPSG:4326&LAYERS=${layers}&FORMAT=image/jpeg&WIDTH=800&HEIGHT=800`; + try { + const buf = await httpsGetBuffer(url); + if (buf.length < 1000) return { success: false, error: '위성사진을 받지 못했습니다 (해당 날짜/좌표에 이미지 없음일 수 있음)' }; + const workspacePath = getWorkspacePath(args); + const outDir = path.join(workspacePath, 'satellite-images'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + const filePath = path.join(outDir, `satellite-${lat.toFixed(2)}_${lon.toFixed(2)}-${date}-${Date.now()}.jpg`); + fs.writeFileSync(filePath, buf); + return { + success: true, + stdout: `위성사진 (${date}, lat=${lat}, lon=${lon}, ${layerKey}):\n\n${buildImageMarkdown(filePath, workspacePath)}`, + }; + } catch (err: any) { + return { success: false, error: `위성사진 요청 실패: ${err.message}` }; + } + }, +}; diff --git a/web-ui/html/weather-app.html b/web-ui/html/weather-app.html index 1dbad51..1a73be0 100644 --- a/web-ui/html/weather-app.html +++ b/web-ui/html/weather-app.html @@ -70,6 +70,15 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon /* ── Center ── */ .wt-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;} +/* Center tab bar (map + opened satellite photos) */ +.wt-center-tabs{display:flex;gap:2px;padding:6px 8px 0;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;overflow-x:auto;} +.wt-center-tabs::-webkit-scrollbar{height:3px;} +.wt-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;border:none;border-radius:8px 8px 0 0;font-size:12px;font-weight:600;color:var(--muted);background:var(--bg-soft,var(--panel-2));cursor:pointer;font-family:inherit;white-space:nowrap;transition:.12s;} +.wt-tab:hover{color:var(--text);} +.wt-tab.active{color:#38bdf8;background:var(--bg);} +.wt-tab-close{opacity:.55;padding:0 2px;border-radius:4px;} +.wt-tab-close:hover{opacity:1;background:rgba(239,68,68,.2);color:#f87171;} + /* Map control bar */ .wt-map-bar{padding:6px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;} .wt-map-row1{display:flex;gap:6px;align-items:center;margin-bottom:5px;} @@ -90,6 +99,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon /* Map iframe */ .wt-map-area{flex:1;position:relative;overflow:hidden;} +.wt-photo-zoom-ctrl{position:absolute;right:12px;bottom:12px;display:flex;align-items:center;gap:2px;background:rgba(0,0,0,.55);border-radius:8px;padding:4px;backdrop-filter:blur(4px);} +.wt-photo-zoom-ctrl button{width:26px;height:26px;border:none;border-radius:6px;background:rgba(255,255,255,.08);color:#e5e7eb;font-size:15px;font-weight:700;cursor:pointer;line-height:1;font-family:inherit;} +.wt-photo-zoom-ctrl button:hover{background:rgba(255,255,255,.18);} +.wt-photo-zoom-ctrl span{color:#e5e7eb;font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;width:38px;text-align:center;} .wt-map-frame{width:100%;height:100%;border:none;display:block;} .wt-map-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;background:var(--bg);pointer-events:none;transition:opacity .3s;} .wt-map-loading.hidden{opacity:0;} @@ -215,6 +228,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon +
@@ -227,9 +241,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon - +
-
+
+