diff --git a/package.json b/package.json index ae84929..73f6997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.1.1", + "version": "4.1.2", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/src/tools/weather.ts b/src/tools/weather.ts index c4a4603..f43164f 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -1,9 +1,11 @@ import { ToolResult } from '../types.js'; import { getConfig } from '../config/config.js'; import { getVault } from '../security/vault.js'; -import { getPaths } from '../config/paths.js'; +import { getPaths, getWorkspacePath } from '../config/paths.js'; +import { buildImageMarkdown } from './image.js'; import { spawn } from 'child_process'; import path from 'path'; +import fs from 'fs'; import https from 'https'; // IPv4-forced HTTPS GET — used for hosts whose IPv6 is unreachable from this server @@ -1361,3 +1363,115 @@ export const weatherCmip6Tool = { } }, }; + +// ── weather_map_screenshot (headless capture of Windy embed map) ───────────── +// The Windy iframe embedded in the weather app UI is rendered client-side for +// the human user only — the model never sees it. This tool actually opens the +// same embed URL in a headless browser and screenshots it, so the model can +// look at the rendered map instead of guessing what its own coordinates show. + +let _mapPlaywrightModule: any = null; +let _mapBrowser: any = null; +let _mapChromiumPath: string | null | undefined = undefined; // undefined = not probed yet + +async function findMapChromiumExecutable(): Promise { + const candidates = [ + process.env.CHROME_PATH, + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/snap/bin/chromium', + ].filter(Boolean) as string[]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return null; +} + +async function getMapBrowser(): Promise { + if (_mapBrowser) { + try { + await _mapBrowser.version(); + return _mapBrowser; + } catch { + _mapBrowser = null; + } + } + if (!_mapPlaywrightModule) { + if (!process.env.PLAYWRIGHT_BROWSERS_PATH) { + const os = await import('os'); + process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(os.homedir(), '.playwright-browsers'); + } + _mapPlaywrightModule = await (Function('return import("playwright")')() as Promise); + } + if (_mapChromiumPath === undefined) _mapChromiumPath = await findMapChromiumExecutable(); + _mapBrowser = await _mapPlaywrightModule.chromium.launch({ + headless: true, + ...(_mapChromiumPath ? { executablePath: _mapChromiumPath } : {}), + }); + return _mapBrowser; +} + +export const weatherMapScreenshotTool = { + name: 'weather_map_screenshot', + description: 'Capture a real screenshot of a rendered Windy weather map (radar, satellite, wind, precipitation, etc.) so you can actually SEE it before describing it. Call this with the exact embed.windy.com URL you just generated — do not guess what the map shows from the URL parameters alone.', + schema: { + url: 'The exact https://embed.windy.com/embed2.html?... URL to capture (must match the one shown to the user)', + wait_ms: 'Milliseconds to wait for map tiles to finish loading before capturing (default 4000, max 8000)', + }, + jsonSchema: { + type: 'object', + properties: { + url: { type: 'string', description: 'The exact embed.windy.com URL to capture' }, + wait_ms: { type: 'number', description: 'Wait time in ms for tiles to load (default 4000, max 8000)' }, + }, + required: ['url'], + additionalProperties: false, + }, + execute: async (args: any): Promise => { + const urlStr = String(args?.url || '').trim(); + if (!urlStr) return { success: false, error: 'url is required' }; + + let parsed: URL; + try { parsed = new URL(urlStr); } catch { return { success: false, error: 'Invalid URL' }; } + if (parsed.hostname !== 'embed.windy.com') { + return { success: false, error: 'Only embed.windy.com URLs can be captured' }; + } + + const waitMs = Math.min(Math.max(Number(args?.wait_ms) || 4000, 1000), 8000); + + let context: any = null; + try { + const browser = await getMapBrowser(); + // Bigger viewport than before (was 900x620) for more legible city labels/numbers — + // safe because JPEG capture below keeps the file small despite the larger canvas. + context = await browser.newContext({ viewport: { width: 1280, height: 880 } }); + const page = await context.newPage(); + await page.goto(parsed.toString(), { waitUntil: 'load', timeout: 20_000 }); + await page.waitForTimeout(waitMs); + // Capture as JPEG (not PNG) so the file lands under the 300KB threshold in + // resolveToolImageContent (server-v2.ts) that otherwise re-downscales anything + // bigger to a blurry 600x600 thumbnail — losing exactly the city-name/temperature + // text and color-band precision the model needs to read the map accurately. + const buf: Buffer = await page.screenshot({ type: 'jpeg', quality: 90 }); + + const workspacePath = getWorkspacePath(args); + const outDir = path.join(workspacePath, 'weather-maps'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + const filePath = path.join(outDir, `windy-${Date.now()}.jpg`); + fs.writeFileSync(filePath, buf); + + const overlay = parsed.searchParams.get('overlay') || 'map'; + const zoom = parsed.searchParams.get('zoom') || '?'; + return { + success: true, + stdout: `Windy 지도 캡처 완료 (overlay=${overlay}, zoom=${zoom}):\n\n${buildImageMarkdown(filePath, workspacePath)}\n\n위 이미지에 실제로 보이는 내용만 근거로 설명하세요. 보이지 않는 세부사항을 추측하지 마세요.`, + }; + } catch (err: any) { + return { success: false, error: `지도 캡처 실패: ${err.message}` }; + } finally { + try { if (context) await context.close(); } catch {} + } + }, +}; diff --git a/web-ui/weather-app.html b/web-ui/weather-app.html index 909671f..b01b1c1 100644 --- a/web-ui/weather-app.html +++ b/web-ui/weather-app.html @@ -266,10 +266,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
🌤️ 기상 AI + 전체 - +
@@ -325,7 +326,9 @@ function saveModel(){selectedModel=document.getElementById('model-sel').value;tr const APP_KEY='wt'; let skillContext=''; let _sessionId=null; -function getSessionId(){return _sessionId||'wt_main';} +let currentLocKey=null; +function locSessionKey(l){return 'wt_loc_'+String(l.lat).replace(/[^0-9a-zA-Z]/g,'_')+'x'+String(l.lon).replace(/[^0-9a-zA-Z]/g,'_');} +function getSessionId(){return currentLocKey||_sessionId||'wt_main';} async function initSessionId(){ try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{} _sessionId='wt_'+Math.random().toString(36).slice(2,12); @@ -354,7 +357,19 @@ async function loadHistory(){ } -const SKILL_CONTENT="---\nname: Meteorologist\ndescription: \ub0a0\uc528 \ubd84\uc11d\u00b7\uae30\ud6c4 \ud574\uc124\u00b7\uae30\uc0c1 \ud604\uc0c1 \uc124\uba85\u00b7\uc608\ubcf4 \ud574\uc11d\uc744 \uc81c\uacf5\ud558\ub294 \uae30\uc0c1 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \uc815\ud655\ud558\uace0 \uc26c\uc6b4 \uc124\uba85 \uc81c\uacf5.\nemoji: \"\ud83c\udf24\ufe0f\"\nversion: 1.3.0\ntriggers: [\ub0a0\uc528, \uae30\uc628, \uae30\uc0c1, \ube44, \ub208, \ubc14\ub78c, \ud0dc\ud48d, \uc608\ubcf4, \ubbf8\uc138\uba3c\uc9c0, \ud669\uc0ac, \ud3ed\uc5fc, \ud55c\ud30c, \uc2b5\ub3c4, \uac15\uc218, \ub300\uae30\uc9c8, \uae30\ud6c4, \uad6c\ub984, \uc6b0\ubc15, \ubc88\uac1c, \ucc9c\ub465, \uc548\uac1c, \uc11c\ub9ac, \uc5f4\ub300\uc57c, \uc18c\ub098\uae30]\n---\n\n\ub2f9\uc2e0\uc740 \uae30\uc0c1\ud559 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \ub0a0\uc528 \uc608\ubcf4 \ud574\uc11d, \uae30\uc0c1 \ud604\uc0c1 \uc124\uba85, \uae30\ud6c4 \ubd84\uc11d, \uc790\uc5f0\uc7ac\ud574 \uacbd\ubcf4 \ud574\uc124\uc744 \uc81c\uacf5\ud558\uc138\uc694.\n\n**\uc5b8\uc5b4**: \uc0ac\uc6a9\uc790\uac00 \ud55c\uad6d\uc5b4\ub85c \ub9d0\ud558\uba74 \ubc18\ub4dc\uc2dc \ud55c\uad6d\uc5b4\ub85c\ub9cc \ub2f5\ubcc0. \uae30\uc0c1 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4 \uad04\ud638 \ubcd1\uae30. (\uc608: \uc801\ub780\uc6b4(cumulonimbus))\n\n---\n\n### \ud575\uc2ec \uc6d0\uce59\n\n**\u26d4 \ub0a0\uc528 \uc218\uce58 \ubb34\ub2e8 \uc0dd\uc131 \uae08\uc9c0**: \uc2e4\uc2dc\uac04\u00b7\uacfc\uac70 \uae30\uc0c1 \uc218\uce58\ub294 \ubc18\ub4dc\uc2dc \ub3c4\uad6c\ub97c \ud638\ucd9c\ud55c \ud6c4 \ubc18\ud658\ub41c \ub370\uc774\ud130\ub9cc \ubcf4\uace0\ud558\uc138\uc694. \ub3c4\uad6c \uc5c6\uc774 \ub0a0\uc528\ub97c \uc11c\uc220\ud558\uba74 \uc624\ubcf4\uc785\ub2c8\ub2e4.\n\n**\u26d4 \uc774\uc804 \ub300\ud654 \uc218\uce58 \uc7ac\uc0ac\uc6a9 \uae08\uc9c0**: \uac19\uc740 \uc9c0\uc5ed\u00b7\ub0a0\uc9dc\ub77c\ub3c4 \uc0c8 \uc9c8\ubb38\ub9c8\ub2e4 \ub3c4\uad6c\ub97c \uc0c8\ub85c \ud638\ucd9c\ud558\uc138\uc694.\n\n- \ubd88\ud655\uc2e4\ud55c \uc608\ubcf4\ub294 \"\uae30\uc0c1\uccad \ucd5c\uc2e0 \uc608\ubcf4\ub97c \ud655\uc778\ud558\uc138\uc694\"\ub85c \uba85\uc2dc\n- \uc218\uce58 \uc778\uc6a9 \uc2dc \ucd9c\ucc98(\uae30\uc0c1\uccad\u00b7ECMWF\u00b7NOAA \ub4f1) \ud45c\uae30\n- \uc804\ubb38 \uc6a9\uc5b4 \uc0ac\uc6a9 \uc2dc \uc26c\uc6b4 \ub9d0\ub85c \ud55c \uc904 \ubcd1\uae30\n- \uc704\ud5d8 \uae30\uc0c1 \uc2dc \ud589\ub3d9 \uc694\ub839 \uc6b0\uc120 \uc548\ub0b4\n\n---\n\n### \ud83d\uddfa\ufe0f Windy \uc9c0\ub3c4 \uc784\ubca0\ub4dc\n\n\uc0ac\uc6a9\uc790 \uba54\uc2dc\uc9c0\uc5d0 \uc544\ub798 **\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**\uac00 \ud3ec\ud568\ub420 \ub54c\ub9cc Windy URL\uc744 \ucd9c\ub825\ud558\uc138\uc694. \uadf8 \uc678\uc5d0\ub294 \uc808\ub300 \ucd9c\ub825\ud558\uc9c0 \ub9c8\uc138\uc694.\n\n**\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**: \uae30\uc0c1\ub3c4, \uc704\uc131\uc0ac\uc9c4, \uc704\uc131\uc601\uc0c1, \ub808\uc774\ub354, \uac15\uc218\ub808\uc774\ub354, \ubc14\ub78c\uc9c0\ub3c4, \ud48d\ud5a5\uc9c0\ub3c4, \uae30\uc555\ub3c4, \uc77c\uae30\ub3c4, \uad6c\ub984\uc0ac\uc9c4, \uad6c\ub984\uc601\uc0c1, \uc801\uc124\uc9c0\ub3c4, \ud30c\uace0\uc9c0\ub3c4\n\n\ud2b8\ub9ac\uac70 \ub2e8\uc5b4\uac00 \uc788\uc744 \ub54c: `https://embed.windy.com/embed2.html?lat=37.5&lon=126.9&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` \ud615\uc2dd\uc73c\ub85c OVERLAY\u00b7ZOOM\uc744 \ub9e5\ub77d\uc5d0 \ub9de\uac8c \ucc44\uc6cc **\ub3c5\ub9bd\ub41c \ud55c \uc904**\ub85c \ucd9c\ub825\ud558\uc138\uc694.\n\noverlay: `rain`(\uac15\uc218) `wind`(\ubc14\ub78c) `temp`(\uae30\uc628) `clouds`(\uad6c\ub984) `pressure`(\uae30\uc555) `pm2p5`(\ub300\uae30\uc9c8) `radar`(\ub808\uc774\ub354) `snowcover`(\uc801\uc124) `waves`(\ud30c\uace0)\n\nzoom: 5=\ud55c\ubc18\ub3c4 \uc804\uccb4, 7=\uc218\ub3c4\uad8c, 10=\uc2dc\ub0b4\n\n---\n\n### \ub3c4\uad6c\n\n| \ub3c4\uad6c | \uc6a9\ub3c4 |\n|---|---|\n| `weather_kma` | \ud55c\uad6d \ub0a0\uc528 (\ucd08\ub2e8\uae30\uc2e4\ud669\u00b7\ub2e8\uae30\uc608\ubcf4, \uc815\ud655\ub3c4 \ucd5c\uc6b0\uc120) |\n| `weather_search` | \ud574\uc678 \ub0a0\uc528\u00b75\uc77c \uc608\ubcf4 (OpenWeather) |\n| `weather_openmeteo` | \uc2dc\uac04\ubcc4 \uc0c1\uc138 \uc608\ubcf4\u00b7UV\u00b7\uac15\uc218\ud655\ub960, \ucd5c\ub300 16\uc77c |\n| `weather_airkorea` | \ud55c\uad6d \uc2e4\uc2dc\uac04 \ub300\uae30\uc9c8 (PM2.5/PM10/O3) |\n| `weather_airpollution` | \ud574\uc678 \ub3c4\uc2dc \ub300\uae30\uc9c8 |\n| `weather_era5` | \uacfc\uac70 \ub0a0\uc528 \uc870\ud68c (1940\ub144~5\uc77c \uc804) |\n| `weather_nasa_power` | 30\ub144 \uae30\ud6c4 \ud3c9\uade0\u00b7\uc6d4\ubcc4\u00b7\uc77c\ubcc4 (1981\ub144~\ud604\uc7ac) |\n| `weather_cmip6` | \ubbf8\ub798 \uae30\ud6c4 \ucd94\uc774 (1950~2050) |\n| `weather_cds` | SSP \uc2dc\ub098\ub9ac\uc624 \ube44\uad50\u00b7\uc555\ub825\uba74 \ub370\uc774\ud130 |\n| `web_search` | \ud0dc\ud48d \uacbd\ub85c, \uae30\uc0c1 \ud2b9\ubcf4, \uae30\ud6c4 \ub274\uc2a4 |\n| `python_eval` | \uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218\u00b7\uc774\uc2ac\uc810 \uacc4\uc0b0 |\n| `image_read` | \ub808\uc774\ub354 \uc601\uc0c1\u00b7\uc704\uc131 \uc0ac\uc9c4\u00b7\uc77c\uae30\ub3c4 \ubd84\uc11d |\n\n**\ub3c4\uad6c \uc120\ud0dd**: \ud55c\uad6d \ub0a0\uc528\u2192`weather_kma` / \ud574\uc678\u2192`weather_search` / \uc2dc\uac04\ubcc4 \uc0c1\uc138\u2192`weather_openmeteo` / \ud55c\uad6d \ub300\uae30\uc9c8\u2192`weather_airkorea` / \uacfc\uac70\u2192`weather_era5` / \uae30\ud6c4 \ud3c9\uade0\u2192`weather_nasa_power` / \ubbf8\ub798\u2192`weather_cmip6` / \ub274\uc2a4\u00b7\ud2b9\ubcf4\u2192`web_search`\n\n**`weather_nasa_power` \ubcc0\uc218**: `T2M`(\uae30\uc628) `T2M_MAX/MIN`(\ucd5c\uace0/\ucd5c\uc800) `PRECTOTCORR`(\uac15\uc218\ub7c9) `RH2M`(\uc2b5\ub3c4) `WS2M`(\ud48d\uc18d) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(\uae30\uc555) `T2MDEW`(\uc774\uc2ac\uc810) | temporal: `climatology`(30\ub144) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` \ubcc0\uc218**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: \uc5f0\uc0b0\uc790**: \ud55c\uad6d \ub0a0\uc528\u2192`weather.go.kr` / \ud0dc\ud48d\u2192`typ.kma.go.kr` / ECMWF\u2192`ecmwf.int` / \ud5c8\ub9ac\ucf00\uc778\u2192`nhc.noaa.gov` / \uae30\ud6c4\u2192`climate.go.kr` / \ub808\uc774\ub354\u2192`radar.weather.go.kr` / \ub300\uae30\uc9c8\u2192`airkorea.or.kr`\n\n---\n\n### \uae30\uc0c1 \ud604\uc0c1 \uae30\uc900\uce58\n\n| \ud604\uc0c1 | \uae30\uc900 |\n|---|---|\n| \uc9d1\uc911\ud638\uc6b0 | 1h 30mm\u2191 \ub610\ub294 12h 80mm\u2191 |\n| \ud0dc\ud48d | \ucd5c\ub300\ud48d\uc18d 17m/s\u2191 (\uac15\ud48d: 10\ubd84 \ud3c9\uade0 14m/s\u2191) |\n| \ud3ed\uc5fc | \uc77c\ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 (\uc8fc\uc758\ubcf4: 33\u2103\u2191), \uc5f4\ub300\uc57c: \ucd5c\uc800 25\u2103\u2191 |\n| \ud55c\ud30c | \uc804\ub0a0 \ub300\ube44 10\u2103\u2191 \ud558\uac15, \ucd5c\uc800 -12\u2103\u2193 |\n| \ud669\uc0ac | PM2.5 \ub098\uc068 36\u03bcg/m\u00b3\u2191, \ub9e4\uc6b0\ub098\uc068 76\u03bcg/m\u00b3\u2191 |\n\n**\uae30\uc0c1 \ud2b9\ubcf4**:\n\n| \ud2b9\ubcf4 | \uc8fc\uc758\ubcf4 | \uacbd\ubcf4 |\n|---|---|---|\n| \uac15\ud48d | \ud3c9\uade0 14m/s \ub610\ub294 \uc21c\uac04 20m/s | \ud3c9\uade0 21m/s \ub610\ub294 \uc21c\uac04 26m/s |\n| \ud638\uc6b0 | 3h 60mm \ub610\ub294 12h 110mm | 3h 90mm \ub610\ub294 12h 180mm |\n| \ub300\uc124 | 24h 5cm\u2191 | 24h 20cm\u2191 |\n| \ud3ed\uc5fc | \ucd5c\uace0 33\u2103\u2191 2\uc77c\u2191 | \ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 |\n| \ud55c\ud30c | -12\u2103\u2193 | -15\u2103\u2193 |\n\n**\uc704\ud5d8 \uae30\uc0c1 \ud589\ub3d9 \uc694\ub839**: \ud0dc\ud48d\u00b7\uac15\ud48d\u2192\ub099\ud558\ubb3c \uc2e4\ub0b4 \uc774\ub3d9, \ud574\uc548 \uae08\uc9c0 / \uc9d1\uc911\ud638\uc6b0\u2192\uc9c0\ud558\uacf5\uac04 \ub300\ud53c, \ud558\ucc9c \uc811\uadfc \uae08\uc9c0 / \ud3ed\uc5fc\u2192\ub0ae 12~17\uc2dc \uc57c\uc678 \ucd5c\uc18c\ud654, \uc218\ubd84 1.5L\u2191 / \ud55c\ud30c\u2192\ubc29\ud55c\ubcf5, \uc218\ub3c4\uad00 \ub3d9\ud30c \ubc29\uc9c0 / \ub300\uc124\u2192\uc2a4\ub178\uccb4\uc778, \ubbf8\ub044\ub7fc \uc8fc\uc758\n\n**\uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218 \uacc4\uc0b0** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=\u2103, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=\u2103, RH=%\n```\n\n---\n\n**\uc751\ub2f5 \uc6d0\uce59**: \uc218\uce58 \uad6c\uccb4\uc801\uc73c\ub85c(\"\uac15\uc218 \ud655\ub960 70%, \uc624\ud6c4 2\uc2dc\uacbd\ubd80\ud130\"), \ud45c\u00b7\ubaa9\ub85d\uc73c\ub85c \uac00\ub3c5\uc131, 3\uc77c \uc774\ud6c4\ub294 \ubd88\ud655\uc2e4\uc131 \uba85\uc2dc, \uc2e4\uc6a9 \uc870\uc5b8 \ud3ec\ud568(\"\uc6b0\uc0b0 \ucc59\uae30\uc138\uc694\").\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uae30\uc0c1 \uc815\ubcf4 \ud574\uc124 \ubaa9\uc801\uc774\uba70, \uc911\uc694\ud55c \uacb0\uc815\uc740 \uae30\uc0c1\uccad(weather.go.kr) \ucd5c\uc2e0 \uc608\ubcf4\ub97c \uae30\uc900\uc73c\ub85c \ud558\uc138\uc694.\n"; +let _chatCtxLocKey='__unset__'; +async function ensureChatContextForLoc(){ + if(_chatCtxLocKey===currentLocKey)return; + _chatCtxLocKey=currentLocKey; + const locs=getLocs();const l=currentLocKey?locs.find(x=>locSessionKey(x)===currentLocKey):null; + const label=document.getElementById('wt-chat-ctx-label'); + if(label)label.textContent=l?l.name:'전체'; + document.getElementById('chat-msgs').innerHTML=''; + await loadHistory(); + addMsg('system',l?`📍 '${escHtml(l.name)}' 위치 컨텍스트입니다. 이 대화는 이 위치에서만 이어집니다.`:'🌍 전체 컨텍스트입니다. (선택된 위치 없음)'); +} + +const SKILL_CONTENT="---\nname: Meteorologist\ndescription: 날씨 분석·기후 해설·기상 현상 설명·예보 해석을 제공하는 기상 전문가 어시스턴트. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 정확하고 쉬운 설명 제공.\nemoji: \"🌤️\"\nversion: 1.3.0\ntriggers: [날씨, 기온, 기상, 비, 눈, 바람, 태풍, 예보, 미세먼지, 황사, 폭염, 한파, 습도, 강수, 대기질, 기후, 구름, 우박, 번개, 천둥, 안개, 서리, 열대야, 소나기]\n---\n\n당신은 기상학 전문가 어시스턴트입니다. 기상청 공식 데이터와 세계 기상 자료를 바탕으로 날씨 예보 해석, 기상 현상 설명, 기후 분석, 자연재해 경보 해설을 제공하세요.\n\n**언어**: 사용자가 한국어로 말하면 반드시 한국어로만 답변. 기상 용어는 한국어 먼저, 영어 괄호 병기. (예: 적란운(cumulonimbus))\n\n---\n\n### 핵심 원칙\n\n**⛔ 날씨 수치 무단 생성 금지**: 실시간·과거 기상 수치는 반드시 도구를 호출한 후 반환된 데이터만 보고하세요. 도구 없이 날씨를 서술하면 오보입니다.\n\n**⛔ 이전 대화 수치 재사용 금지**: 같은 지역·날짜라도 새 질문마다 도구를 새로 호출하세요.\n\n- 불확실한 예보는 \"기상청 최신 예보를 확인하세요\"로 명시\n- 수치 인용 시 출처(기상청·ECMWF·NOAA 등) 표기\n- 전문 용어 사용 시 쉬운 말로 한 줄 병기\n- 위험 기상 시 행동 요령 우선 안내\n\n---\n\n### 🗺️ Windy 지도 임베드\n\n사용자 메시지에 아래 **트리거 단어**가 포함될 때만 Windy URL을 출력하세요. 그 외에는 절대 출력하지 마세요.\n\n**트리거 단어**: 기상도, 위성사진, 위성영상, 레이더, 강수레이더, 바람지도, 풍향지도, 기압도, 일기도, 구름사진, 구름영상, 적설지도, 파고지도, 태풍, 태풍위치, 태풍경로, 허리케인, 사이클론\n\n트리거 단어가 있을 때: `https://embed.windy.com/embed2.html?lat=LAT&lon=LON&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` 형식으로 LAT·LON·OVERLAY·ZOOM을 맥락에 맞게 채워 **독립된 한 줄**로 출력하세요.\n\n**LAT·LON은 항상 실제로 묻는 지역 기준으로 바꾸세요** — 서울(37.5, 126.9)을 기본값처럼 고정해서 복사하지 마세요. 지역이 특정되지 않았다면 사용자의 기본 지역(서울)을 쓰세요.\n\n**주요 지역 좌표**: 서울(37.5,126.9) 부산(35.18,129.08) 인천(37.46,126.7) 대구(35.87,128.6) 광주(35.16,126.85) 대전(36.35,127.38) 제주(33.5,126.53) 강릉(37.75,128.9) 평양(39.02,125.75) 도쿄(35.68,139.69) 베이징(39.9,116.4) 상하이(31.23,121.47) — 목록에 없는 지역은 알고 있는 좌표를 사용하세요.\n\n**중요**: URL을 출력한 직후 반드시 `weather_map_screenshot` 도구를 그 URL 그대로 호출해서 실제로 렌더링된 지도를 확인하세요. 캡처한 이미지를 보기 전에는 강수·구름·레이더 패턴을 절대 추측하지 마세요 — 이미지에 실제로 보이는 것만 근거로 설명하세요.\n\n**태풍 위치/경로 요청 시**: lat·lon을 서울이나 기본 지역이 아니라 **태풍의 실제 현재 위치**로 맞추세요. 순서: ① `web_search` 또는 `weather_search`로 태풍의 현재 좌표(위도·경도)를 먼저 확인 → ② 그 좌표를 lat·lon에 대입 → ③ zoom은 태풍 전체 규모가 보이도록 4~5(넓게) 사용 → ④ overlay는 `wind`(바람 강도로 태풍 눈·나선구조 확인) 또는 `rain`을 우선 사용. 태풍 좌표를 모른 채 임의 지역으로 지도를 띄우지 마세요.\n\noverlay: `rain`(강수) `wind`(바람) `temp`(기온) `clouds`(구름) `pressure`(기압) `pm2p5`(대기질) `radar`(레이더) `snowcover`(적설) `waves`(파고)\n\nzoom: 5=한반도 전체, 7=수도권, 10=시내\n\n**지도 읽는 법** (오버레이별 색상 해석 — 색상만으로 정확한 수치를 추측하지 말고, 화면에 찍힌 숫자·범례만 근거로 삼으세요):\n- `wind`(바람): 흰색 흐름선이 바람 방향·세기, 하단 색상바(kt 단위)가 풍속 범례. 파랑→초록→노랑→주황→빨강/보라 순으로 강해짐\n- `rain`/`radar`(강수/레이더): 색이 진할수록 강수강도가 강함. 연파랑(약한 비)→초록→노랑→빨강→보라(매우 강함). radar는 예보가 아닌 실시간 관측값\n- `temp`(기온): 파랑(추움)→초록/노랑(온화)→주황/빨강(더움). 도시 위 숫자는 실제 기온 값이므로 그대로 인용 가능\n- `clouds`(구름): 흰색·회색이 진할수록 구름이 두꺼움(구름량 많음), 투명하면 맑음\n- `pressure`(기압): 등압선(같은 기압을 잇는 곡선)과 H(고기압)·L(저기압) 표시. 등압선 간격이 좁을수록 바람이 강함\n- `pm2p5`(대기질): 초록(좋음)→노랑(보통)→주황/빨강(나쁨)→보라/적갈색(매우나쁨)\n- `snowcover`(적설): 흰색·연파랑(적음)→진파랑/보라(많음)\n- `waves`(파고): 파랑(잔잔)→초록/노랑(보통)→주황/빨강(높음·위험). 화살표는 파도 진행 방향\n- 좌하단 타임라인은 지도가 보여주는 시각(날짜·시간)을 나타냄 — 반드시 확인하고 그 시점 기준으로 설명\n\n---\n\n### 도구\n\n| 도구 | 용도 |\n|---|---|\n| `weather_kma` | 한국 날씨 (초단기실황·단기예보, 정확도 최우선) |\n| `weather_search` | 해외 날씨·5일 예보 (OpenWeather) |\n| `weather_openmeteo` | 시간별 상세 예보·UV·강수확률, 최대 16일 |\n| `weather_airkorea` | 한국 실시간 대기질 (PM2.5/PM10/O3) |\n| `weather_airpollution` | 해외 도시 대기질 |\n| `weather_era5` | 과거 날씨 조회 (1940년~5일 전) |\n| `weather_nasa_power` | 30년 기후 평균·월별·일별 (1981년~현재) |\n| `weather_cmip6` | 미래 기후 추이 (1950~2050) |\n| `weather_cds` | SSP 시나리오 비교·압력면 데이터 |\n| `web_search` | 태풍 경로, 기상 특보, 기후 뉴스 |\n| `python_eval` | 체감온도·불쾌지수·이슬점 계산 |\n| `weather_map_screenshot` | Windy 지도를 실제로 캡처해서 확인 (URL 출력 직후 반드시 호출) |\n| `image_read` | 사용자가 업로드한 이미지 분석(OCR) |\n\n**도구 선택**: 한국 날씨→`weather_kma` / 해외→`weather_search` / 시간별 상세→`weather_openmeteo` / 한국 대기질→`weather_airkorea` / 과거→`weather_era5` / 기후 평균→`weather_nasa_power` / 미래→`weather_cmip6` / 뉴스·특보→`web_search`\n\n**`weather_nasa_power` 변수**: `T2M`(기온) `T2M_MAX/MIN`(최고/최저) `PRECTOTCORR`(강수량) `RH2M`(습도) `WS2M`(풍속) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(기압) `T2MDEW`(이슬점) | temporal: `climatology`(30년) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` 변수**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: 연산자**: 한국 날씨→`weather.go.kr` / 태풍→`typ.kma.go.kr` / ECMWF→`ecmwf.int` / 허리케인→`nhc.noaa.gov` / 기후→`climate.go.kr` / 레이더→`radar.weather.go.kr` / 대기질→`airkorea.or.kr`\n\n---\n\n### 기상 현상 기준치\n\n| 현상 | 기준 |\n|---|---|\n| 집중호우 | 1h 30mm↑ 또는 12h 80mm↑ |\n| 태풍 | 최대풍속 17m/s↑ (강풍: 10분 평균 14m/s↑) |\n| 폭염 | 일최고 35℃↑ 2일↑ (주의보: 33℃↑), 열대야: 최저 25℃↑ |\n| 한파 | 전날 대비 10℃↑ 하강, 최저 -12℃↓ |\n| 황사 | PM2.5 나쁨 36μg/m³↑, 매우나쁨 76μg/m³↑ |\n\n**기상 특보**:\n\n| 특보 | 주의보 | 경보 |\n|---|---|---|\n| 강풍 | 평균 14m/s 또는 순간 20m/s | 평균 21m/s 또는 순간 26m/s |\n| 호우 | 3h 60mm 또는 12h 110mm | 3h 90mm 또는 12h 180mm |\n| 대설 | 24h 5cm↑ | 24h 20cm↑ |\n| 폭염 | 최고 33℃↑ 2일↑ | 최고 35℃↑ 2일↑ |\n| 한파 | -12℃↓ | -15℃↓ |\n\n**위험 기상 행동 요령**: 태풍·강풍→낙하물 실내 이동, 해안 금지 / 집중호우→지하공간 대피, 하천 접근 금지 / 폭염→낮 12~17시 야외 최소화, 수분 1.5L↑ / 한파→방한복, 수도관 동파 방지 / 대설→스노체인, 미끄럼 주의\n\n**체감온도·불쾌지수 계산** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=℃, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=℃, RH=%\n```\n\n---\n\n**응답 원칙**: 수치 구체적으로(\"강수 확률 70%, 오후 2시경부터\"), 표·목록으로 가독성, 3일 이후는 불확실성 명시, 실용 조언 포함(\"우산 챙기세요\").\n\n이 서비스는 기상 정보 해설 목적이며, 중요한 결정은 기상청(weather.go.kr) 최신 예보를 기준으로 하세요.\n"; function loadSkill(){ skillContext=SKILL_CONTENT; document.getElementById('skill-badge').textContent='🌤️ 기상 전문가 활성'; @@ -379,7 +394,7 @@ function renderLocList(){ const locs=getLocs(); const container=document.getElementById('loc-list'); container.innerHTML=`
${locs.map((l,i)=>` -
+
${escHtml(l.name)} ${l.lat.toFixed(2)}, ${l.lon.toFixed(2)} @@ -439,9 +454,20 @@ function saveEditLoc(i){ const lat=parseFloat(document.getElementById('ef-lat-'+i)?.value||''); const lon=parseFloat(document.getElementById('ef-lon-'+i)?.value||''); if(!name||isNaN(lat)||isNaN(lon))return; - const arr=getLocs();arr[i]={name,lat,lon};saveLocs(arr);renderLocList(); + const arr=getLocs();const wasKey=arr[i]?locSessionKey(arr[i]):null;arr[i]={name,lat,lon};saveLocs(arr);renderLocList(); + if(currentLocKey===wasKey&¤tLocKey===locSessionKey(arr[i])){ + const label=document.getElementById('wt-chat-ctx-label');if(label)label.textContent=name; + } +} +function deleteLoc(i){ + const arr=getLocs();const l=arr[i]; + if(l){ + const key=locSessionKey(l); + fetch('/api/chat/sessions/'+encodeURIComponent(key),{method:'DELETE',headers:authH()}).catch(()=>{}); + if(currentLocKey===key){currentLocKey=null;ensureChatContextForLoc();} + } + arr.splice(i,1);saveLocs(arr);renderLocList(); } -function deleteLoc(i){const arr=getLocs();arr.splice(i,1);saveLocs(arr);renderLocList();} // ── Windy map ───────────────────────────────────────────────────────────────── let mapLat=37.5,mapLon=127.0,mapZoom=6,mapOverlay='radar'; @@ -463,14 +489,17 @@ function setOverlay(ov){ updateMap(); } -function goToLoc(lat,lon,name){ +function goToLoc(lat,lon,name,idx){ mapLat=lat;mapLon=lon; document.getElementById('map-loc').value=`${lat},${lon}`; document.querySelectorAll('.wt-loc').forEach(el=>el.classList.remove('active')); updateMap(); // active highlight - const locs=getLocs();const idx=locs.findIndex(l=>l.lat===lat&&l.lon===lon); + const locs=getLocs(); + if(idx==null)idx=locs.findIndex(l=>l.lat===lat&&l.lon===lon); if(idx>=0)document.getElementById('loc-'+idx)?.classList.add('active'); + currentLocKey=idx>=0&&locs[idx]?locSessionKey(locs[idx]):null; + ensureChatContextForLoc(); } function gotoLocation(){ @@ -639,10 +668,14 @@ async function callChat(message){ } async function clearChat(){ - if(!confirm('대화를 지울까요?'))return; + if(!confirm('이 컨텍스트의 대화를 지울까요?'))return; + const sid=getSessionId(); document.getElementById('chat-msgs').innerHTML=''; - _sessionId='wt_'+Math.random().toString(36).slice(2,12); - try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} + try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{} + if(!currentLocKey){ + _sessionId='wt_'+Math.random().toString(36).slice(2,12); + try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{} + } addMsg('system','새 대화가 시작되었습니다. 날씨를 질문해주세요.'); } function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}} @@ -683,7 +716,7 @@ function mobToggle(which){ document.getElementById('map-loading').classList.remove('hidden'); document.getElementById('windy-frame').src=windyUrl(mapLat,mapLon,mapZoom,mapOverlay); await Promise.all([initSessionId(),loadSkill(),loadModels()]); - await loadHistory(); + await ensureChatContextForLoc(); addMsg('system','🌤️ 날씨 어시스턴트입니다. 지역 날씨, 예보, 기상 현상을 질문하세요.'); initAppVoice(sendMessage); document.getElementById('chat-input').focus();