v4.3.19-7: NASA EONET/Worldview 위성사진 기능 + weather-app 멀티탭 뷰어
- eonet_events/satellite_snapshot 도구 추가 (키 불필요 공개 API) - weather-app.html: 위성사진 채팅 이미지 렌더링, 지도/사진 멀티탭 센터패널, 확대/축소·드래그팬, localStorage+히스토리 이중 복원으로 새로고침 시 탭 유지(하드리셋으로 localStorage가 지워져도 대화 이력에서 재구성)
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,''')}','${ov}')">🗺️ ${label} 지도 보기</span>`);
|
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url.replace(/'/g,''')}','${ov}')">🗺️ ${label} 지도 보기</span>`);
|
||||||
return ph;
|
return ph;
|
||||||
});
|
});
|
||||||
|
// Markdown images (e.g. satellite_snapshot's ) — 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,'"')}" alt="${alt.replace(/"/g,'"')}" style="max-width:100%;border-radius:8px;margin:6px 0;display:block">`);
|
||||||
|
return ph;
|
||||||
|
});
|
||||||
out=out
|
out=out
|
||||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
.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);
|
||||||
|
|||||||
Reference in New Issue
Block a user