v4.3.19-7: NASA EONET/Worldview 위성사진 기능 + weather-app 멀티탭 뷰어

- eonet_events/satellite_snapshot 도구 추가 (키 불필요 공개 API)
- weather-app.html: 위성사진 채팅 이미지 렌더링, 지도/사진 멀티탭 센터패널,
  확대/축소·드래그팬, localStorage+히스토리 이중 복원으로 새로고침 시
  탭 유지(하드리셋으로 localStorage가 지워져도 대화 이력에서 재구성)
This commit is contained in:
kim
2026-07-26 18:43:20 +09:00
parent a80ad9e809
commit 071e36110e
5 changed files with 383 additions and 6 deletions
+33
View File
@@ -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', type: 'function',
function: { function: {
+4
View File
@@ -244,6 +244,9 @@ async function handleChat(
// biggest remaining pair) only make sense once a PDF is actually referenced. // biggest remaining pair) only make sense once a PDF is actually referenced.
const hasPdfAttachment = /\.pdf\b|pdf\s*(파일|읽어|추출)/i.test(message); const hasPdfAttachment = /\.pdf\b|pdf\s*(파일|읽어|추출)/i.test(message);
const hasScheduleKeyword = /예약|스케줄|반복|매일|매주|매월|정기적|주기적|cron|schedule|recurring/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 meteorologistEnabled = isSkillEnabledForUser('meteorologist', userWorkspace);
const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace); const lawyerEnabled = isSkillEnabledForUser('lawyer', userWorkspace);
const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace); const presenterEnabled = isSkillEnabledForUser('presenter', userWorkspace);
@@ -322,6 +325,7 @@ async function handleChat(
if (name === 'news_search' && !hasNewsKeyword) return false; if (name === 'news_search' && !hasNewsKeyword) return false;
if ((name === 'pdf_read' || name === 'pdf_extract_images') && !hasPdfAttachment) return false; if ((name === 'pdf_read' || name === 'pdf_extract_images') && !hasPdfAttachment) return false;
if (name === 'schedule_job' && !hasScheduleKeyword) 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; if (name === 'run_command' && !hasRunCommandKeyword) return false;
return true; return true;
}; };
+4
View File
@@ -22,6 +22,7 @@ import { audioTranscribeTool } from './audio-transcribe.js';
import { pythonEvalTool } from './python.js'; import { pythonEvalTool } from './python.js';
import { sqliteTool } from './sqlite.js'; import { sqliteTool } from './sqlite.js';
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.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 { newsSearchTool } from './news.js';
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js'; import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
import { kakaoSendTool } from './kakao.js'; import { kakaoSendTool } from './kakao.js';
@@ -223,6 +224,9 @@ class ToolRegistry {
this.registerSafe(weatherCdsTool); this.registerSafe(weatherCdsTool);
this.registerSafe(weatherCmip6Tool); this.registerSafe(weatherCmip6Tool);
this.registerSafe(weatherMapScreenshotTool); 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) // News (NewsData.io — real headlines with real publish dates, for news queries)
this.registerSafe(newsSearchTool); this.registerSafe(newsSearchTool);
// Email tools (IMAP + SMTP) // Email tools (IMAP + SMTP)
+163
View File
@@ -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<any> {
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<Buffer> {
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<string, string> = {
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<ToolResult> => {
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<string, string> = {
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<ToolResult> => {
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}` };
}
},
};
+179 -6
View File
@@ -70,6 +70,15 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
/* ── Center ── */ /* ── Center ── */
.wt-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;} .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 */ /* Map control bar */
.wt-map-bar{padding:6px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;} .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;} .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 */ /* Map iframe */
.wt-map-area{flex:1;position:relative;overflow:hidden;} .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-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{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;} .wt-map-loading.hidden{opacity:0;}
@@ -215,6 +228,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<button class="wt-qbtn" onclick="askWeather('uv')">☀️ 자외선 지수</button> <button class="wt-qbtn" onclick="askWeather('uv')">☀️ 자외선 지수</button>
<button class="wt-qbtn" onclick="askWeather('climate')">📊 기후 분석</button> <button class="wt-qbtn" onclick="askWeather('climate')">📊 기후 분석</button>
<button class="wt-qbtn" onclick="askWeather('past')">📜 과거 날씨</button> <button class="wt-qbtn" onclick="askWeather('past')">📜 과거 날씨</button>
<button class="wt-qbtn" onclick="askWeather('satellite')">🛰️ 지구 자연재해·위성사진</button>
</div> </div>
<div class="wt-divider"></div> <div class="wt-divider"></div>
@@ -227,9 +241,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
</div> </div>
</aside> </aside>
<!-- ── Center: Windy map ── --> <!-- ── Center: Windy map + satellite photo tabs ── -->
<section class="wt-center"> <section class="wt-center">
<div class="wt-map-bar"> <div class="wt-center-tabs" id="wt-center-tabs"></div>
<div class="wt-map-bar" id="wt-map-bar">
<div class="wt-map-row1"> <div class="wt-map-row1">
<input class="wt-loc-input" id="map-loc" type="text" placeholder="위도,경도 또는 도시명 (예: 37.5,127.0 또는 서울)" value="37.5,127.0" onkeydown="if(event.key==='Enter')gotoLocation()"> <input class="wt-loc-input" id="map-loc" type="text" placeholder="위도,경도 또는 도시명 (예: 37.5,127.0 또는 서울)" value="37.5,127.0" onkeydown="if(event.key==='Enter')gotoLocation()">
<select class="wt-zoom-sel" id="map-zoom" onchange="updateMap()"> <select class="wt-zoom-sel" id="map-zoom" onchange="updateMap()">
@@ -254,10 +269,19 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<button class="wt-ov" data-ov="waves" onclick="setOverlay('waves')">🌊 파고</button> <button class="wt-ov" data-ov="waves" onclick="setOverlay('waves')">🌊 파고</button>
</div> </div>
</div> </div>
<div class="wt-map-area"> <div class="wt-map-area" id="wt-map-area">
<div class="wt-map-loading" id="map-loading">🌍 Windy 지도 로딩 중...</div> <div class="wt-map-loading" id="map-loading">🌍 Windy 지도 로딩 중...</div>
<iframe class="wt-map-frame" id="windy-frame" src="about:blank" allow="geolocation" onload="document.getElementById('map-loading').classList.add('hidden')"></iframe> <iframe class="wt-map-frame" id="windy-frame" src="about:blank" allow="geolocation" onload="document.getElementById('map-loading').classList.add('hidden')"></iframe>
</div> </div>
<div class="wt-map-area" id="wt-photo-area" style="display:none;align-items:center;justify-content:center;background:#0a0e14">
<img id="wt-photo-img" src="" alt="" draggable="false" style="max-width:100%;max-height:100%;object-fit:contain;transform-origin:center center;transition:transform .08s linear;user-select:none">
<div class="wt-photo-zoom-ctrl">
<button onclick="wtPhotoZoomOut()" title="축소">−</button>
<span id="wt-photo-zoom-pct">100%</span>
<button onclick="wtPhotoZoomIn()" title="확대">+</button>
<button onclick="wtPhotoZoomReset()" title="원래 크기">⟲</button>
</div>
</div>
</section> </section>
<!-- ── Resize ── --> <!-- ── Resize ── -->
@@ -348,10 +372,18 @@ async function loadHistory(){
const recent=msgs.slice(-30); const recent=msgs.slice(-30);
const el=document.getElementById('chat-msgs'); const el=document.getElementById('chat-msgs');
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep); const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
let sawPhoto=false;
for(const m of recent){ for(const m of recent){
if(m.role==='user')addMsg('user',escHtml(m.content||'')); if(m.role==='user'){addMsg('user',escHtml(m.content||''));continue;}
else addMsg('assistant',renderMdSafe(m.content||'')); addMsg('assistant',renderMdSafe(m.content||''));
// Chat history is the durable source of truth for which satellite photos were
// shown — rebuild tabs from it too, not just localStorage, so a cleared/cold
// browser (private mode, "clear on exit", different device) still gets them back.
const re=/!\[([^\]]*)\]\((\/api\/files\/satellite-images\/[^)\s]+)\)/g;
let mm;
while((mm=re.exec(m.content||''))){addPhotoTab(mm[2],mm[1]);sawPhoto=true;}
} }
if(sawPhoto)renderCenterTabs();
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2); const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
el.scrollTop=el.scrollHeight; el.scrollTop=el.scrollHeight;
}catch{} }catch{}
@@ -513,6 +545,131 @@ function gotoLocation(){
} }
// Intercept Windy URL from AI response and load in map // Intercept Windy URL from AI response and load in map
// ── Center panel tabs (map + satellite photos) ──────────────────────────────
let wtTabs=[{id:'map',type:'map',label:'🗺️ 지도'}];
let wtActiveTab='map';
const WT_TABS_LS_KEY='wt_photo_tabs';
function saveWtTabs(){
try{
const photos=wtTabs.filter(t=>t.type==='photo').map(t=>({id:t.id,label:t.label,url:t.url}));
localStorage.setItem(WT_TABS_LS_KEY,JSON.stringify(photos));
}catch{}
}
function restoreWtTabs(){
try{
const raw=localStorage.getItem(WT_TABS_LS_KEY);
if(!raw)return;
const photos=JSON.parse(raw);
if(Array.isArray(photos)){
for(const p of photos){
if(p&&p.id&&p.url&&!wtTabs.some(t=>t.id===p.id)) wtTabs.push({id:p.id,type:'photo',label:p.label||'🛰️ 위성사진',url:p.url});
}
}
}catch{}
}
function renderCenterTabs(){
document.getElementById('wt-center-tabs').innerHTML=wtTabs.map(t=>
`<button class="wt-tab${t.id===wtActiveTab?' active':''}" onclick="switchCenterTab('${t.id}')">${t.label}${t.type==='photo'?`<span class="wt-tab-close" onclick="event.stopPropagation();closeCenterTab('${t.id}')">✕</span>`:''}</button>`
).join('');
}
function switchCenterTab(id){
const tab=wtTabs.find(t=>t.id===id);
if(!tab)return;
wtActiveTab=id;
document.getElementById('wt-map-bar').style.display=tab.type==='map'?'block':'none';
document.getElementById('wt-map-area').style.display=tab.type==='map'?'block':'none';
const photoArea=document.getElementById('wt-photo-area');
if(tab.type==='photo'){
photoArea.style.display='flex';
document.getElementById('wt-photo-img').src=tab.url;
document.getElementById('wt-photo-img').alt=tab.label;
wtPhotoZoomReset(); // each photo starts fresh, not wherever the last one was left zoomed/panned
}else{
photoArea.style.display='none';
}
renderCenterTabs();
}
// ── Satellite photo zoom/pan ─────────────────────────────────────────────────
let wtPhotoZoom=1,wtPhotoPanX=0,wtPhotoPanY=0;
let wtPhotoDragging=false,wtPhotoDragStartX=0,wtPhotoDragStartY=0,wtPhotoPanStartX=0,wtPhotoPanStartY=0;
function wtApplyPhotoTransform(){
const img=document.getElementById('wt-photo-img');
img.style.transform=`translate(${wtPhotoPanX}px,${wtPhotoPanY}px) scale(${wtPhotoZoom})`;
img.style.cursor=wtPhotoZoom>1?(wtPhotoDragging?'grabbing':'grab'):'default';
document.getElementById('wt-photo-zoom-pct').textContent=Math.round(wtPhotoZoom*100)+'%';
}
function wtPhotoZoomIn(){wtPhotoZoom=Math.min(wtPhotoZoom*1.4,8);wtApplyPhotoTransform();}
function wtPhotoZoomOut(){
wtPhotoZoom=Math.max(wtPhotoZoom/1.4,1);
if(wtPhotoZoom<=1){wtPhotoZoom=1;wtPhotoPanX=0;wtPhotoPanY=0;}
wtApplyPhotoTransform();
}
function wtPhotoZoomReset(){wtPhotoZoom=1;wtPhotoPanX=0;wtPhotoPanY=0;wtApplyPhotoTransform();}
(function initPhotoZoomInteraction(){
const area=document.getElementById('wt-photo-area');
const img=document.getElementById('wt-photo-img');
area.addEventListener('wheel',(e)=>{
if(wtActiveTab==='map')return;
e.preventDefault();
if(e.deltaY<0)wtPhotoZoomIn();else wtPhotoZoomOut();
},{passive:false});
img.addEventListener('mousedown',(e)=>{
if(wtPhotoZoom<=1)return;
e.preventDefault();
wtPhotoDragging=true;
wtPhotoDragStartX=e.clientX;wtPhotoDragStartY=e.clientY;
wtPhotoPanStartX=wtPhotoPanX;wtPhotoPanStartY=wtPhotoPanY;
wtApplyPhotoTransform();
});
window.addEventListener('mousemove',(e)=>{
if(!wtPhotoDragging)return;
wtPhotoPanX=wtPhotoPanStartX+(e.clientX-wtPhotoDragStartX);
wtPhotoPanY=wtPhotoPanStartY+(e.clientY-wtPhotoDragStartY);
wtApplyPhotoTransform();
});
window.addEventListener('mouseup',()=>{
if(!wtPhotoDragging)return;
wtPhotoDragging=false;
wtApplyPhotoTransform();
});
img.addEventListener('dblclick',()=>{wtPhotoZoom>1?wtPhotoZoomReset():wtPhotoZoomIn();});
})();
function addPhotoTab(url,label){
// Re-use an existing tab for the same image instead of stacking duplicates
// (e.g. the model mentioning the same photo again in a follow-up reply).
const existing=wtTabs.find(t=>t.type==='photo'&&t.url===url);
if(existing)return existing.id;
const id='photo-'+Date.now()+'-'+Math.random().toString(36).slice(2,7);
wtTabs.push({id,type:'photo',label:'🛰️ '+(label||'위성사진'),url});
saveWtTabs();
return id;
}
function openPhotoTab(url,label){
switchCenterTab(addPhotoTab(url,label));
}
function closeCenterTab(id){
wtTabs=wtTabs.filter(t=>t.id!==id);
saveWtTabs();
if(wtActiveTab===id)switchCenterTab('map');
else renderCenterTabs();
}
// Auto-open the first satellite_snapshot image found in a reply, mirroring processWindyUrl.
function processSatelliteImage(text){
const m=text.match(/!\[([^\]]*)\]\((\/api\/files\/satellite-images\/[^)\s]+)\)/);
if(!m)return false;
openPhotoTab(m[2],m[1]);
return true;
}
function processWindyUrl(text){ function processWindyUrl(text){
const m=text.match(/https:\/\/embed\.windy\.com\/embed2\.html\?([^\s"'<>]+)/); const m=text.match(/https:\/\/embed\.windy\.com\/embed2\.html\?([^\s"'<>]+)/);
if(!m)return false; if(!m)return false;
@@ -556,6 +713,7 @@ function askWeather(type){
climate:`${loc}의 월별 기후 평균(기온·강수량)을 30년 기준으로 알려주세요.`, climate:`${loc}의 월별 기후 평균(기온·강수량)을 30년 기준으로 알려주세요.`,
past:`${loc} 오늘 날짜 기준 역대 같은 날 날씨 기록을 알려주세요.`, past:`${loc} 오늘 날짜 기준 역대 같은 날 날씨 기록을 알려주세요.`,
alert:`현재 ${loc} 및 전국 기상 특보(태풍·호우·폭염·한파·강풍·풍랑·황사)가 발효 중인지 알려주세요. 특보 종류, 대상 지역, 해제 예상 시간을 포함해주세요.`, alert:`현재 ${loc} 및 전국 기상 특보(태풍·호우·폭염·한파·강풍·풍랑·황사)가 발효 중인지 알려주세요. 특보 종류, 대상 지역, 해제 예상 시간을 포함해주세요.`,
satellite:`요즘 지구에서 진행 중인 자연재해(태풍·산불·화산·지진 등)를 알려주고, 가장 주목할 만한 것 하나는 실제 위성사진으로 보여주세요.`,
}; };
sendToChat(msgs[type]||'날씨를 알려주세요.'); sendToChat(msgs[type]||'날씨를 알려주세요.');
} }
@@ -586,6 +744,14 @@ function renderMdSafe(text){
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url.replace(/'/g,'&#39;')}','${ov}')">🗺️ ${label} 지도 보기</span>`); windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url.replace(/'/g,'&#39;')}','${ov}')">🗺️ ${label} 지도 보기</span>`);
return ph; return ph;
}); });
// Markdown images (e.g. satellite_snapshot's ![name](/api/files/...)) — extracted
// before escaping/paragraph-wrapping like the Windy links above, same reason this
// renderer never needed image support until the satellite feature.
out=out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g,(_m,alt,url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`; // shares windyLinks' placeholder/restore mechanism below
windyLinks.push(`<img src="${url.replace(/"/g,'&quot;')}" alt="${alt.replace(/"/g,'&quot;')}" style="max-width:100%;border-radius:8px;margin:6px 0;display:block">`);
return ph;
});
out=out out=out
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>') .replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
@@ -628,7 +794,7 @@ async function callChat(message){
const typingDiv=document.createElement('div');typingDiv.className='wt-msg assistant'; const typingDiv=document.createElement('div');typingDiv.className='wt-msg assistant';
typingDiv.innerHTML='<div class="wt-typing"><div class="wt-dot"></div><div class="wt-dot"></div><div class="wt-dot"></div></div>'; typingDiv.innerHTML='<div class="wt-typing"><div class="wt-dot"></div><div class="wt-dot"></div><div class="wt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight; msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='',windyLoaded=false; let bubble=null,textBuf='',thinkBuf='',windyLoaded=false,satPhotoLoaded=false;
try{ try{
const body={message,sessionId:getSessionId(),useTools:true, const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}), ...(selectedModel?{model:selectedModel}:{}),
@@ -658,10 +824,15 @@ async function callChat(message){
if(!windyLoaded&&textBuf.includes('embed.windy.com')){ if(!windyLoaded&&textBuf.includes('embed.windy.com')){
if(processWindyUrl(textBuf)){windyLoaded=true;} if(processWindyUrl(textBuf)){windyLoaded=true;}
} }
// Auto-open first satellite photo found
if(!satPhotoLoaded&&textBuf.includes('/api/files/satellite-images/')){
if(processSatelliteImage(textBuf)){satPhotoLoaded=true;}
}
} }
} }
} }
if(!windyLoaded&&textBuf)processWindyUrl(textBuf); if(!windyLoaded&&textBuf)processWindyUrl(textBuf);
if(!satPhotoLoaded&&textBuf)processSatelliteImage(textBuf);
}catch(e){ }catch(e){
typingDiv.remove(); typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';} if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
@@ -713,6 +884,8 @@ function mobToggle(which){
(async()=>{ (async()=>{
const ok=await checkAuth();if(!ok)return; const ok=await checkAuth();if(!ok)return;
renderLocList(); renderLocList();
restoreWtTabs();
renderCenterTabs();
// 지도는 인증 직후 바로 로드 (세션 ID와 무관) // 지도는 인증 직후 바로 로드 (세션 ID와 무관)
document.getElementById('map-loading').classList.remove('hidden'); document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=windyUrl(mapLat,mapLon,mapZoom,mapOverlay); document.getElementById('windy-frame').src=windyUrl(mapLat,mapLon,mapZoom,mapOverlay);