Files
homeclaw/web-ui/html/weather-app.html
T
kimandClaude Opus 5 d60019f004 fix: 앱 페이지 7개에 수식 렌더러 배선 — 애초에 KaTeX가 없었다
법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**

  index.html        katex O
  lawyer / detective / doctor / investor / accountant / mind / weather   전부 X

메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).

addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:35 +09:00

1097 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🌤️ 날씨 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
:root{--brand:#38bdf8;}
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;font-weight:500;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.wt-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.wt-hdr h1{font-size:13px;font-weight:700;margin:0;}
.wt-hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:#38bdf8;color:#38bdf8;}
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(56,189,248,.1);border:1px solid rgba(56,189,248,.25);color:#7dd3fc;font-weight:700;}
/* ── Layout ── */
.wt-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.wt-sb{width:190px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;overflow-y:auto;}
.wt-sb::-webkit-scrollbar{width:3px;}
.wt-sb::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.wt-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;display:flex;align-items:center;flex-shrink:0;}
.wt-sb-sec span{flex:1;}
.wt-add-btn{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:0 4px;transition:.12s;line-height:1;}
.wt-add-btn:hover{color:#38bdf8;}
/* Saved locations */
.wt-locs{padding:0 6px 4px;}
.wt-loc{display:flex;align-items:center;gap:4px;padding:5px 8px;border-radius:6px;cursor:pointer;transition:.12s;border:1px solid transparent;}
.wt-loc:hover{background:rgba(56,189,248,.05);border-color:rgba(56,189,248,.15);}
.wt-loc:hover .wt-loc-acts{opacity:1;}
.wt-loc.active{background:rgba(56,189,248,.08);border-color:rgba(56,189,248,.25);}
.wt-loc-info{flex:1;min-width:0;}
.wt-loc-name{font-size:11px;font-weight:700;color:var(--text);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.wt-loc-sub{font-size:10px;font-weight:500;color:var(--muted);display:block;}
.wt-loc.active .wt-loc-sub{color:#7dd3fc;}
.wt-loc-acts{display:flex;gap:1px;opacity:0;transition:.12s;flex-shrink:0;}
.wt-loc-act{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:2px 3px;border-radius:3px;transition:.12s;line-height:1;}
.wt-loc-act:hover{color:#38bdf8;background:rgba(56,189,248,.1);}
.wt-loc-act.del:hover{color:#ef4444;background:rgba(239,68,68,.1);}
/* Add/edit form */
.wt-loc-form{padding:5px 6px;border:1px solid rgba(56,189,248,.3);border-radius:7px;background:rgba(56,189,248,.04);margin:2px 6px;}
.wt-lf-row{display:flex;gap:4px;margin-bottom:4px;align-items:center;}
.wt-lf-input{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;outline:none;min-width:0;}
.wt-lf-input:focus{border-color:#38bdf8;}
.wt-lf-btns{display:flex;gap:4px;}
.wt-lf-save{background:#0c4a6e;border:none;border-radius:5px;padding:3px 9px;font-size:11px;font-weight:700;color:#7dd3fc;cursor:pointer;font-family:inherit;}
.wt-lf-cancel{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
.wt-divider{height:1px;background:var(--line);margin:4px 6px;}
/* Quick prompts */
.wt-quick{padding:0 6px 6px;}
.wt-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;width:100%;margin-bottom:3px;line-height:1.4;}
.wt-qbtn:hover{border-color:#38bdf8;color:#38bdf8;background:rgba(56,189,248,.04);}
/* Alert badges */
.wt-alert-box{margin:6px;padding:8px 10px;background:rgba(239,68,68,.07);border:1px solid rgba(239,68,68,.2);border-radius:8px;font-size:10px;color:var(--muted);line-height:1.7;}
.wt-alert-box strong{color:#f87171;display:block;margin-bottom:3px;}
/* ── Center ── */
.wt-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;}
/* Center tab bar (map + opened satellite photos) */
.wt-center-tabs{display:flex;gap:2px;padding:6px 8px 0;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;overflow-x:auto;}
.wt-center-tabs::-webkit-scrollbar{height:3px;}
.wt-tab{display:flex;align-items:center;gap:6px;padding:6px 12px;border:none;border-radius:8px 8px 0 0;font-size:12px;font-weight:600;color:var(--muted);background:var(--bg-soft,var(--panel-2));cursor:pointer;font-family:inherit;white-space:nowrap;transition:.12s;}
.wt-tab:hover{color:var(--text);}
.wt-tab.active{color:#38bdf8;background:var(--bg);}
.wt-tab-close{opacity:.55;padding:0 2px;border-radius:4px;}
.wt-tab-close:hover{opacity:1;background:rgba(239,68,68,.2);color:#f87171;}
/* Map control bar */
.wt-map-bar{padding:6px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
.wt-map-row1{display:flex;gap:6px;align-items:center;margin-bottom:5px;}
.wt-loc-input{flex:1;background:var(--panel-2);border:1.5px solid var(--line);border-radius:7px;padding:5px 10px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.wt-loc-input:focus{border-color:#38bdf8;}
.wt-zoom-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 7px;font-size:11px;color:var(--text);font-family:inherit;}
.wt-go-btn{background:#0c4a6e;border:none;border-radius:7px;padding:5px 12px;font-size:12px;font-weight:700;color:#7dd3fc;cursor:pointer;font-family:inherit;transition:.15s;white-space:nowrap;}
.wt-go-btn:hover{background:#0e5a80;}
/* Overlay tabs */
.wt-overlays{display:flex;gap:4px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;}
.wt-overlays::-webkit-scrollbar{height:3px;}
.wt-overlays::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.wt-ov{padding:3px 9px;border:1.5px solid var(--line);border-radius:16px;font-size:11px;font-weight:600;cursor:pointer;background:none;color:var(--muted);font-family:inherit;transition:.12s;white-space:nowrap;}
.wt-ov:hover{border-color:#38bdf8;color:#38bdf8;}
.wt-ov.active{border-color:#0ea5e9;background:#082f49;color:#7dd3fc;}
[data-theme="light"] .wt-ov.active{background:#e0f2fe;color:#0369a1;}
/* Map iframe */
.wt-map-area{flex:1;position:relative;overflow:hidden;}
.wt-photo-zoom-ctrl{position:absolute;right:12px;bottom:12px;display:flex;align-items:center;gap:2px;background:rgba(0,0,0,.55);border-radius:8px;padding:4px;backdrop-filter:blur(4px);}
.wt-photo-zoom-ctrl button{width:26px;height:26px;border:none;border-radius:6px;background:rgba(255,255,255,.08);color:#e5e7eb;font-size:15px;font-weight:700;cursor:pointer;line-height:1;font-family:inherit;}
.wt-photo-zoom-ctrl button:hover{background:rgba(255,255,255,.18);}
.wt-photo-zoom-ctrl span{color:#e5e7eb;font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;width:38px;text-align:center;}
.wt-photo-goto-map{position:absolute;left:12px;bottom:12px;background:rgba(0,0,0,.55);border:none;border-radius:8px;padding:7px 12px;color:#e5e7eb;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;backdrop-filter:blur(4px);}
.wt-photo-goto-map:hover{background:rgba(255,255,255,.18);}
.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:color-mix(in srgb,var(--bg) 85%,transparent);pointer-events:none;transition:opacity .3s;}
/* Windy's embed centers on lat/lon but draws no pin there, so for a "where exactly is
the typhoon" request we drop our own arrow at the exact center — no projection math
needed since the map is always centered on the same coordinates we asked it to load. */
.wt-map-marker{position:absolute;left:50%;top:50%;transform:translate(-50%,-100%);font-size:30px;line-height:1;pointer-events:none;z-index:5;filter:drop-shadow(0 1px 3px rgba(0,0,0,.7));}
.wt-map-marker.hidden{display:none;}
.wt-map-loading.hidden{opacity:0;}
/* ── Resize ── */
.wt-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.wt-resize:hover,.wt-resize.dragging{background:#38bdf8;}
/* ── Chat ── */
.wt-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.wt-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.wt-chat-label{font-size:10px;font-weight:700;color:#7dd3fc;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.wt-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
.wt-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.wt-chat-clear:hover{color:#ef4444;}
.wt-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.wt-msgs::-webkit-scrollbar{width:3px;}
.wt-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.wt-msg{display:flex;flex-direction:column;}
.wt-msg.user{align-items:flex-end;}
.wt-msg.assistant{align-items:flex-start;}
.wt-msg.system{align-items:center;}
.wt-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.wt-msg.user .wt-bubble{background:#082f49;color:#e0f2fe;border-radius:10px 10px 3px 10px;}
.wt-msg.assistant .wt-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.wt-msg.system .wt-bubble{background:transparent;color:var(--muted);font-size:11px;}
.wt-bubble p{margin:3px 0;}
.wt-bubble h3{font-size:13px;margin:6px 0 3px;}
.wt-bubble ul,.wt-bubble ol{padding-left:16px;margin:3px 0;}
.wt-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.wt-bubble strong{font-weight:700;}
.wt-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
.wt-bubble th,.wt-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
.wt-bubble th{background:var(--panel);font-weight:700;}
/* Windy link chip in chat */
.wt-map-link{display:inline-block;margin:4px 0;padding:4px 10px;background:rgba(56,189,248,.1);border:1px solid rgba(56,189,248,.25);border-radius:6px;font-size:11px;color:#38bdf8;cursor:pointer;}
.wt-map-link:hover{background:rgba(56,189,248,.2);}
.wt-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
.wt-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
.wt-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.wt-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:wtDot 1.2s ease-in-out infinite;}
.wt-dot:nth-child(2){animation-delay:.2s;}.wt-dot:nth-child(3){animation-delay:.4s;}
@keyframes wtDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.wt-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.wt-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
.wt-textarea:focus{border-color:#38bdf8;}
.wt-send{background:#0c4a6e;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#7dd3fc;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.wt-send:hover:not(:disabled){background:#0e5a80;}
.wt-send:disabled{opacity:.5;cursor:not-allowed;}
/* ── Mobile ── */
@media(max-width:1024px){
.wt-hdr{flex-wrap:wrap;padding:5px 10px;gap:5px;}
/* Sidebar → left overlay */
.wt-sb{
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:78vw;max-width:280px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.4);
}
.wt-sb.mob-open{transform:translateX(0);}
/* Chat → right overlay */
.wt-chat{
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:360px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 24px rgba(0,0,0,.4);
}
.wt-chat.mob-open{transform:translateX(0);}
/* Hide resize handle */
.wt-resize{display:none;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--line);height:52px;}
.wt-layout{padding-bottom:52px;}
.wt-center{border:none;}
textarea,input,select{font-size:16px !important;}
}
@media(min-width:1025px){
#mob-backdrop{display:none !important;}
#mob-tabbar{display:none !important;}
}
</style>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<script src="../js/voice/voice-utils.js"></script>
</head>
<body>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-sb" onclick="mobToggle('sb')"><span style="font-size:18px">📍</span>위치</button>
<button class="mob-tab active" id="mobtab-map" onclick="mobCloseAll()"><span style="font-size:18px">🗺</span>지도</button>
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')"><span style="font-size:18px">💬</span>채팅</button>
</div>
<header class="wt-hdr">
<a href="/" class="hdr-btn">← 홈</a>
<h1>🌤️ 날씨 어시스턴트</h1>
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
<div class="wt-hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌙 테마</button>
</header>
<div class="wt-layout">
<!-- ── Sidebar ── -->
<aside class="wt-sb">
<div class="wt-sb-sec"><span>📍 즐겨찾는 위치</span><button class="wt-add-btn" onclick="showLocForm()" title="위치 추가">+</button></div>
<div id="loc-list"></div>
<div class="wt-divider"></div>
<div class="wt-sb-sec"><span>빠른 질문</span></div>
<div class="wt-quick">
<select id="quick-loc-sel" class="wt-lf-input" style="width:100%;margin-bottom:6px;font-size:11px;" onchange="localStorage.setItem('wt_quick_loc',this.value)"></select>
<button class="wt-qbtn" onclick="askWeather('alert')" style="color:#f87171;border-color:rgba(239,68,68,.3);">🚨 기상 특보</button>
<button class="wt-qbtn" onclick="askWeather('today')">☀️ 오늘 날씨</button>
<button class="wt-qbtn" onclick="askWeather('week')">📅 주간 예보</button>
<button class="wt-qbtn" onclick="askWeather('air')">🌫️ 미세먼지·대기질</button>
<button class="wt-qbtn" onclick="askWeather('typhoon')">🌀 태풍 정보</button>
<button class="wt-qbtn" onclick="askWeather('rain')">🌧️ 강수 확률</button>
<button class="wt-qbtn" onclick="askWeather('uv')">☀️ 자외선 지수</button>
<button class="wt-qbtn" onclick="askWeather('climate')">📊 기후 분석</button>
<button class="wt-qbtn" onclick="askWeather('past')">📜 과거 날씨</button>
<button class="wt-qbtn" onclick="askWeather('satellite')">🛰️ 지구 자연재해·위성사진</button>
</div>
<div class="wt-divider"></div>
<div class="wt-alert-box">
<strong>📌 기상 기준</strong>
집중호우: 1h 30mm↑<br>
폭염특보: 33℃↑ 2일↑<br>
한파특보: −12℃↓<br>
PM2.5 나쁨: 36μg/m³↑
</div>
</aside>
<!-- ── Center: Windy map + satellite photo tabs ── -->
<section class="wt-center">
<div class="wt-center-tabs" id="wt-center-tabs"></div>
<div class="wt-map-bar" id="wt-map-bar">
<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()">
<select class="wt-zoom-sel" id="map-zoom" onchange="updateMap()">
<option value="5">한반도</option>
<option value="6" selected>광역</option>
<option value="7">수도권</option>
<option value="9">도시</option>
<option value="11">동네</option>
</select>
<button class="wt-go-btn" onclick="gotoLocation()">🔍 이동</button>
</div>
<div class="wt-overlays">
<button class="wt-ov active" data-ov="satellite" onclick="setOverlay('satellite')">🛰 위성사진</button>
<button class="wt-ov" data-ov="radar" onclick="setOverlay('radar')">📡 레이더</button>
<button class="wt-ov" data-ov="rain" onclick="setOverlay('rain')">🌧 강수</button>
<button class="wt-ov" data-ov="wind" onclick="setOverlay('wind')">💨 바람</button>
<button class="wt-ov" data-ov="jetstream" onclick="setOverlay('jetstream')" title="상층 250hPa(~10km) 바람 흐름 — 제트기류">🌪 제트기류</button>
<button class="wt-ov" data-ov="temp" onclick="setOverlay('temp')">🌡 기온</button>
<button class="wt-ov" data-ov="clouds" onclick="setOverlay('clouds')">☁️ 구름</button>
<button class="wt-ov" data-ov="pm2p5" onclick="setOverlay('pm2p5')">🌫 대기질</button>
<button class="wt-ov" data-ov="pressure" onclick="setOverlay('pressure')">📊 기압</button>
<button class="wt-ov" data-ov="snowcover" onclick="setOverlay('snowcover')">❄️ 적설</button>
<button class="wt-ov" data-ov="waves" onclick="setOverlay('waves')">🌊 파고</button>
<button class="wt-ov" data-ov="kma-track" onclick="setOverlay('kma-track')" title="기상청 태풍 예상진로(원뿔형 확률반경) — Windy엔 없는 실제 진로도">🧭 예상진로</button>
<button class="wt-ov" data-ov="jma-track" onclick="setOverlay('jma-track')" title="일본기상청(RSMC 도쿄) 서태평양 전역 태풍 예상진로 — 기상청 페이지에 안 뜨는 태풍(한국 관할 밖)도 표시">🌏 JMA 예상진로</button>
</div>
</div>
<div class="wt-map-area" id="wt-map-area">
<div class="wt-map-loading" id="map-loading">🌍 Windy 지도 로딩 중...</div>
<iframe class="wt-map-frame" id="windy-frame" src="about:blank" allow="geolocation"></iframe>
<div class="wt-map-marker hidden" id="wt-map-marker" title="태풍 위치(지도 중심)">⬇️</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>
<button class="wt-photo-goto-map" onclick="wtShowPhotoLocationOnMap()" title="이 위치를 지도 탭에서 보기">🗺️ 지도에서 위치 보기</button>
</div>
</section>
<!-- ── Resize ── -->
<div class="wt-resize" id="resize-handle"></div>
<!-- ── Chat ── -->
<div class="wt-chat" id="chat-panel">
<div class="wt-chat-hdr">
<span class="wt-chat-label">🌤️ 기상 AI</span>
<span id="wt-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:90px" title="현재 대화 컨텍스트">전체</span>
<select class="wt-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="wt-chat-clear" onclick="clearChat()" title="이 컨텍스트의 대화 지우기">🗑</button>
</div>
<div class="wt-msgs" id="chat-msgs"></div>
<div class="wt-input-bar">
<textarea class="wt-textarea" id="chat-input" rows="1" placeholder="날씨, 예보, 기상 현상을 질문하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
<button class="wt-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}}
// ── Resize ────────────────────────────────────────────────────────────────────
(function(){
const handle=document.getElementById('resize-handle');
const panel=document.getElementById('chat-panel');
let dragging=false,startX=0,startW=0;
panel.style.width=(parseInt(localStorage.getItem('wt_chat_w')||'340',10))+'px';
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!dragging)return;panel.style.width=Math.max(260,Math.min(600,startW+(startX-e.clientX)))+'px';});
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem('wt_chat_w',panel.offsetWidth);}catch{};});
})();
// ── Model ─────────────────────────────────────────────────────────────────────
let selectedModel=localStorage.getItem('wt_model')||'';
async function loadModels(){
const sel=document.getElementById('model-sel');
try{
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
models.forEach(m=>{const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);});
}catch{}
}
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem('wt_model',selectedModel);}catch{}}
// ── Skill ─────────────────────────────────────────────────────────────────────
const APP_KEY='wt';
let skillContext='';
let _sessionId=null;
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);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
try{
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length)return;
const recent=msgs.slice(-30);
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);
let sawPhoto=false;
for(const m of recent){
if(m.role==='user'){addMsg('user',escHtml(m.content||''));continue;}
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);
el.scrollTop=el.scrollHeight;
}catch{}
}
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**⛔ 외부 사이트 링크가 Windy URL을 대체하면 안 됩니다**: 허리케인/태풍의 이름·좌표를 확인했다면(웹검색으로), StormAtlas·NHC·JTWC 같은 외부 링크를 참고자료로 덧붙이는 것과 별개로 **반드시 그 좌표로 embed.windy.com Windy URL도 출력**하세요 — 한국 태풍이든 다른 대양의 허리케인이든 동일합니다. 좌표를 몰라서 못 만들겠으면 못 만든다고 말하고 외부 링크만 주되, **\"🔍 지도에서 확인할 수 있는 것\" 같은 캡션은 직전에 실제로 embed.windy.com 또는 기상청 예상진로 URL을 출력했을 때만 쓰세요** — 임베드되지 않는 외부 링크(StormAtlas 등)에 이 캡션을 붙이면, 사용자 화면의 지도는 전혀 안 움직이는데 마치 지도에 표시된 것처럼 오해하게 됩니다.\n\n**⛔ 후속 \"이동/이동해줘/옮겨줘/그쪽으로\" 요청도 트리거로 취급**: 직전 대화에서 이미 지도(Windy URL)를 출력한 적이 있고, 사용자가 \"위치로 이동\", \"거기로 옮겨줘\", \"그 사진 위치로\", \"이동하라고\" 등 지도 이동을 요청하면 — 이 메시지 자체에 위 트리거 단어가 없어도 **반드시 새 Windy URL을 다시 출력**하세요. 이런 요청을 무시하고 텍스트 설명만 하거나 `weather_map_screenshot` 결과를 대신 보여주려 하면 사용자가 보는 실제 지도는 전혀 움직이지 않습니다.\n\n**⛔ `weather_map_screenshot`은 당신이 보기 위한 도구입니다 — 그 결과를 `![...](...)` 마크다운으로 답변에 삽입하지 마세요.** 이 도구는 저장되거나 서빙되는 파일을 반환하지 않으므로, 결과를 이미지 링크로 만들면 깨진 링크가 됩니다. 지도를 실제로 사용자 화면에 띄우는 유일한 방법은 위에서 설명한 Windy URL을 **독립된 텍스트 줄로 출력**하는 것뿐입니다 — 스크린샷 도구는 그 다음에 당신이 결과를 확인·설명하기 위해서만 쓰세요.\n\n트리거 단어가 있을 때: `https://embed.windy.com/embed2.html?lat=LAT&lon=LON&zoom=ZOOM&level=LEVEL&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` 형식으로 LAT·LON·OVERLAY·ZOOM·LEVEL을 맥락에 맞게 채워 **독립된 한 줄**로 출력하세요. LEVEL은 보통 `surface`(지상)를 쓰고, **제트기류**를 물으면 `250h`(약 10km 상공)로 바꾸고 overlay는 반드시 `wind`를 쓰세요 — Windy가 그 고도의 바람 흐름선을 그대로 애니메이션으로 그려주므로 화살표를 따로 만들 필요가 없습니다. 이때 zoom은 제트기류의 흐름이 한눈에 보이도록 4~5(넓게) 사용하세요.\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`을 우선 사용 → ⑤ **URL 맨 끝에 `&mark=typhoon`을 붙이세요** — Windy 자체는 태풍 눈의 정확한 위치를 핀으로 찍어주지 않으므로, 화면이 화면 중심(=lat·lon으로 지정한 태풍 좌표)에 ⬇️ 마커를 띄워 정확한 위치를 표시해줍니다(Windy는 이 파라미터를 모르는 값으로 무시하니 지도 자체엔 영향 없음). 태풍 좌표를 모른 채 임의 지역으로 지도를 띄우지 마세요.\n\n**⛔ Windy 임베드(`overlay=wind`/`rain`/`satellite`)에 예상 진로(향후 경로)가 그려진다고 말하지 마세요**: Windy는 **그 순간의 관측·예보 상태만** 보여줄 뿐, 태풍의 향후 이동 경로를 선이나 점으로 표시하지 않습니다. \"노란색-빨간색 점\", \"점선\" 같은 범례를 Windy 지도 위에서 봤다고 지어내지 마세요.\n\n**✅ 진짜 예상 진로(향후 경로) 지도는 기상청 페이지입니다**: 사용자가 \"예상 경로\"/\"진로\"/\"경로\"를 물으면, 다음 URL을 **독립된 텍스트 줄로 그대로 출력**하세요(치환 없이 리터럴 그대로 — lat/lon/overlay 파라미터가 없는 고정 URL입니다): `https://www.weather.go.kr/w/typhoon/ko/weather/typhoon_02.jsp` — 이 페이지는 기상청이 실제로 원뿔형 확률반경(70% 확률 반경)과 예상 진로선을 그려주는 유일한 지도입니다. Windy URL과 마찬가지로 출력 직후 `weather_map_screenshot`으로 그 URL을 캡처해서 실제로 어떤 태풍이 표시되는지 확인한 뒤 설명하세요 — 이 페이지는 그 시점 기상청이 지정한 활성 태풍만 보여주므로, 질문받은 태풍과 다른 태풍이 표시된다면 그렇다고 말하세요.\n\n**✅ 기상청 페이지에 안 뜨는 서태평양 태풍은 JMA를 쓰세요**: 대만·필리핀·괌·일본 인근처럼 아직 한국에 안 걸린 서태평양 태풍은, 그 시점 기상청이 지정한 태풍 하나만 보여주는 위 KMA 페이지에도 안 나옵니다(NHC는 대서양/동·중태평양만 다뤄서 이런 서태평양 태풍엔 아예 해당 안 됨). 이런 경우 다음 URL을 **독립된 텍스트 줄로 그대로 출력**하세요(치환 없이 리터럴 그대로): `https://www.jma.go.jp/bosai/map.html#4/17.686/133.792/&elem=root&typhoon=all&contents=typhoon` — 일본기상청(RSMC 도쿄)이 서태평양 전역을 관할하는 실제 경보센터라 활성 태풍 전부의 예상진로를 한 지도에 그려줍니다. 마찬가지로 출력 직후 `weather_map_screenshot`으로 캡처해서 실제로 어떤 태풍(들)이 표시되는지 확인한 뒤 설명하세요.\n\n**✅ 한국(기상청 관할) 밖의 허리케인/태풍은 NHC를 쓰세요**: 멕시코·미국·대서양·동/중태평양 등 KMA 통보문에 안 뜨는 폭풍은 기상청 페이지에 나오지 않습니다(그 시점 KMA가 지정한 태풍 하나만 보여줌). 이런 경우 먼저 `nhc_active_storms`를 스톰 이름으로 호출해 공식 forecast-track URL(`https://www.nhc.noaa.gov/graphics_XXN.shtml` 형태, 대양+슬롯 번호가 붙음)을 받아오세요 — 이 번호는 이름만으로 추측 불가능하니 절대 직접 조립하지 말고 도구가 돌려준 URL을 **한 글자도 바꾸지 말고 그대로 복사** 하세요 — 숫자 하나만 달라져도(예: ep2 → ep5) 전혀 다른, 이미 소멸했을 수도 있는 폭풍 페이지가 나타납니다. 그 URL을 **독립된 텍스트 줄로 그대로 출력**한 뒤 `weather_map_screenshot`으로 캡처해 확인하고 설명하세요. `nhc_active_storms`가 이름을 못 찾으면 이미 소멸했거나 이름을 잘못 안 것이니, 지어내지 말고 그 사실을 그대로 전달하세요.\n\n**⛔ 태풍 번호·이름 대조 필수**: 기상청 통보문 페이지(weather.go.kr/w/hazard/typhoon/...)는 그 시점 가장 최근 활성 태풍 하나만 보여줄 때가 많습니다 — 페이지에 적힌 태풍 번호(제○호)·이름이 질문받은 태풍과 정확히 일치하는지 반드시 대조한 뒤 인용하세요. 번호를 확인 안 하고 다른 태풍의 좌표·강도·상태를 그대로 가져와 답하면 안 됩니다. 검색 결과에 찾는 이름이 안 보이면 '이미 소멸/상륙했을 가능성'을 의심하고, 한국어 결과 하나에만 의존하지 말고 영어 검색(예: Typhoon 영문명 + current position/live tracking)으로 교차 확인하세요.\n\noverlay: `satellite`(위성사진, 기본값·특정 종류 미지정 시 이걸 사용) `rain`(강수) `wind`(바람) `temp`(기온) `clouds`(구름) `pressure`(기압) `pm2p5`(대기질) `radar`(레이더) `snowcover`(적설) `waves`(파고)\n\nzoom: 5=한반도 전체, 7=수도권, 10=시내\n\n**지도 읽는 법** (오버레이별 색상 해석 — 색상만으로 정확한 수치를 추측하지 말고, 화면에 찍힌 숫자·범례만 근거로 삼으세요):\n- `satellite`(위성사진): 실제 구름·대기 모습을 담은 위성 촬영 영상(적외선/가시광선). 흰색이 짙을수록 두꺼운 구름·강한 대류. 색상 범례가 아니라 실제 촬영 이미지이므로 눈에 보이는 구름 형태·이동을 그대로 설명\n- `wind`(바람): 흰색 흐름선이 바람 방향·세기, 하단 색상바(kt 단위)가 풍속 범례. 파랑→초록→노랑→주황→빨강/보라 순으로 강해짐. **level=250h로 띄웠다면 지상풍이 아니라 제트기류(상층 10km 부근 강풍대)** — 자주색/분홍색 띠가 그 순간 제트기류의 핵심 흐름(위치·방향)\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='🌤️ 기상 전문가 활성';
}
// ── Saved locations ───────────────────────────────────────────────────────────
const LOC_KEY='wt_locations';
const DEFAULT_LOCS=[
{name:'서울',lat:37.5665,lon:126.9780},
{name:'부산',lat:35.1796,lon:129.0756},
{name:'제주',lat:33.4996,lon:126.5312},
{name:'인천',lat:37.4563,lon:126.7052},
];
function getLocs(){try{const v=localStorage.getItem(LOC_KEY);return v?JSON.parse(v):DEFAULT_LOCS;}catch{return DEFAULT_LOCS;}}
function saveLocs(arr){try{localStorage.setItem(LOC_KEY,JSON.stringify(arr));}catch{}}
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
function renderLocList(){
updateQuickLocSel();
const locs=getLocs();
const container=document.getElementById('loc-list');
container.innerHTML=`<div class="wt-locs">${locs.map((l,i)=>`
<div class="wt-loc" id="loc-${i}" onclick="goToLoc(${l.lat},${l.lon},'${escAttr(l.name)}',${i})">
<div class="wt-loc-info">
<span class="wt-loc-name">${escHtml(l.name)}</span>
<span class="wt-loc-sub">${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}</span>
</div>
<div class="wt-loc-acts">
<button class="wt-loc-act" onclick="event.stopPropagation();editLoc(${i})" title="편집">✏</button>
<button class="wt-loc-act del" onclick="event.stopPropagation();deleteLoc(${i})" title="삭제">✕</button>
</div>
</div>`).join('')
}</div>`;
}
function showLocForm(){
closeLocForms();
const container=document.getElementById('loc-list');
const form=document.createElement('div');form.className='wt-loc-form';form.id='add-loc-form';
form.innerHTML=`
<div class="wt-lf-row"><input class="wt-lf-input" id="lf-name" placeholder="위치 이름 (예: 집)"></div>
<div class="wt-lf-row"><input class="wt-lf-input" id="lf-lat" placeholder="위도 (예: 37.5665)" type="number" step="0.0001"></div>
<div class="wt-lf-row"><input class="wt-lf-input" id="lf-lon" placeholder="경도 (예: 126.9780)" type="number" step="0.0001"></div>
<div class="wt-lf-row"><div class="wt-lf-btns">
<button class="wt-lf-save" onclick="saveAddLoc()">추가</button>
<button class="wt-lf-cancel" onclick="closeLocForms()">취소</button>
</div></div>`;
container.appendChild(form);
document.getElementById('lf-name')?.focus();
}
function editLoc(i){
closeLocForms();
const locs=getLocs();const l=locs[i];
const row=document.getElementById('loc-'+i);if(!row)return;
const form=document.createElement('div');form.className='wt-loc-form';form.id='edit-loc-form';
form.innerHTML=`
<div class="wt-lf-row"><input class="wt-lf-input" id="ef-name-${i}" value="${escAttr(l.name)}" placeholder="이름"></div>
<div class="wt-lf-row"><input class="wt-lf-input" id="ef-lat-${i}" value="${l.lat}" placeholder="위도" type="number" step="0.0001"></div>
<div class="wt-lf-row"><input class="wt-lf-input" id="ef-lon-${i}" value="${l.lon}" placeholder="경도" type="number" step="0.0001"></div>
<div class="wt-lf-row"><div class="wt-lf-btns">
<button class="wt-lf-save" onclick="saveEditLoc(${i})">저장</button>
<button class="wt-lf-cancel" onclick="closeLocForms()">취소</button>
</div></div>`;
row.after(form);
document.getElementById('ef-name-'+i)?.focus();
}
function closeLocForms(){document.querySelectorAll('.wt-loc-form').forEach(f=>f.remove());}
function saveAddLoc(){
const name=(document.getElementById('lf-name')?.value||'').trim();
const lat=parseFloat(document.getElementById('lf-lat')?.value||'');
const lon=parseFloat(document.getElementById('lf-lon')?.value||'');
if(!name||isNaN(lat)||isNaN(lon))return;
const arr=getLocs();arr.push({name,lat,lon});saveLocs(arr);renderLocList();
}
function saveEditLoc(i){
const name=(document.getElementById('ef-name-'+i)?.value||'').trim();
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();const wasKey=arr[i]?locSessionKey(arr[i]):null;arr[i]={name,lat,lon};saveLocs(arr);renderLocList();
if(currentLocKey===wasKey&&currentLocKey===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();
}
// ── Windy map ─────────────────────────────────────────────────────────────────
let mapLat=37.5,mapLon=127.0,mapZoom=6,mapOverlay='satellite',mapLevel='surface';
// The one page that actually draws a typhoon's forecast track (cone-of-uncertainty +
// path line) — Windy's embed never plots this no matter which overlay is picked.
const KMA_TYPHOON_URL='https://www.weather.go.kr/w/typhoon/ko/weather/typhoon_02.jsp';
// JMA(RSMC 도쿄)는 서태평양 전역의 실제 관할 경보센터 — KMA 페이지는 그 시점 한국에
// 지정된 태풍 하나만, NHC는 대서양/동태평양/중태평양만 보여줘서, 대만·필리핀·괌·일본
// 인근처럼 한국에 아직 안 걸린 서태평양 태풍은 둘 다 놓친다. typhoon=all이 활성 태풍
// 전부의 예상진로를 한 번에 그려주므로 스톰별 조회 없이 고정 URL 하나로 충분하다.
const JMA_TYPHOON_URL='https://www.jma.go.jp/bosai/map.html#4/17.686/133.792/&elem=root&typhoon=all&contents=typhoon';
// NOAA/NHC's equivalent for storms outside KMA's warning area (Atlantic/Pacific hurricanes).
// Unlike the KMA page this isn't one fixed URL — it's basin+slot specific
// (graphics_ep2.shtml, graphics_at1.shtml, ...), resolved server-side by the
// nhc_active_storms tool, so this is a pattern to recognize rather than a constant to load.
// Query part restricted to word chars only (real NHC keywords are e.g. ?cone, ?gm_track,
// ?wwCone — never punctuation) so a trailing markdown paren/asterisk/hash from the model's
// own link syntax can't get swallowed into the "URL" the way a permissive char class would.
const NHC_TRACK_RE=/https:\/\/www\.nhc\.noaa\.gov\/graphics_[a-z]+\d\.shtml(?:\?[a-zA-Z0-9_]*)?/;
// The 3 regexes elsewhere in this file that match embed.windy.com URLs restrict the query
// part to [a-zA-Z0-9.=&%_-]+ — every value windyUrl() below ever produces is one of those
// characters, so this can't under-match. A permissive [^\s"'<>]+ class used to be here
// instead, which happily swallowed a trailing markdown ")" or "**" from the model's own
// link syntax into the "URL" (same bug independently found and fixed for NHC_TRACK_RE).
// Extra `&mark=typhoon` on an AI-emitted URL (Windy itself ignores unknown query params,
// so this rides along harmlessly) tells us to drop the ⬇️ marker div at the map's center.
function setTyphoonMarker(show){
document.getElementById('wt-map-marker').classList.toggle('hidden',!show);
}
function windyUrl(lat,lon,zoom,overlay,level='surface'){
return `https://embed.windy.com/embed2.html?lat=${lat}&lon=${lon}&zoom=${zoom}&level=${level}&overlay=${overlay}&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C`;
}
// The Windy iframe's own document loads almost instantly, but Windy's internal
// JS then takes several more seconds to fetch/render tiles — an onload-based
// spinner hides right away and leaves a blank iframe with no feedback in between.
// There's no postMessage from embed2.html to know when tiles are actually ready,
// so just hold the spinner up for a fixed window instead.
let wtMapLoadTimer=null;
function wtLoadMap(url){
// JMA sends a CSP `frame-ancestors` header whitelisting only its own mlit.go.jp/jma.go.jp
// family of sites, so any other origin trying to iframe-embed it gets silently refused by
// the browser (KMA's page has no such header, which is why only JMA needs this special case).
// Top-level navigation (window.open) isn't subject to frame-ancestors, so open it there instead.
if(url===JMA_TYPHOON_URL){window.open(url,'_blank');return;}
clearTimeout(wtMapLoadTimer);
document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=url;
wtMapLoadTimer=setTimeout(()=>document.getElementById('map-loading').classList.add('hidden'),4500);
}
function updateMap(){
// kma-track/jma-track ignore lat/lon/zoom — both are fixed pages.
if(mapOverlay==='kma-track'){wtLoadMap(KMA_TYPHOON_URL);return;}
if(mapOverlay==='jma-track'){wtLoadMap(JMA_TYPHOON_URL);return;}
const zoom=parseInt(document.getElementById('map-zoom').value||'6',10);
mapZoom=zoom;
wtLoadMap(windyUrl(mapLat,mapLon,mapZoom,mapOverlay,mapLevel));
}
function setOverlay(ov){
// "jetstream" isn't a real Windy overlay value — it's wind+250h(~10km altitude)
// presented as its own button so it doesn't get buried in the level=surface default.
if(ov==='jetstream'){
mapOverlay='wind';
mapLevel='250h';
}else{
mapOverlay=ov;
mapLevel='surface'; // manual overlay buttons are always the standard surface view; only an AI-emitted URL (processWindyUrl) sets a non-surface level like 250h for jet stream
}
document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===ov));
setTyphoonMarker(false); // manual overlay switches are never the AI's typhoon-position flow
updateMap();
}
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();
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(){
const raw=document.getElementById('map-loc').value.trim();
// parse "lat,lon" format
const m=raw.match(/^(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)$/);
if(m){mapLat=parseFloat(m[1]);mapLon=parseFloat(m[2]);updateMap();return;}
// city name → use geocoding (simple approach: ask AI)
if(raw){sendToChat(`"${raw}"의 위도·경도를 알려주세요. 숫자만 "위도,경도" 형식으로.`);}
}
// 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));
}
// Satellite photo filenames encode their own coordinates (satellite-{lat}_{lon}-...),
// so jumping the interactive map to a photo's location doesn't need the chat model at
// all — client-side lookup is instant and can't be skipped/hallucinated like relying
// on the model to re-emit a Windy URL on a follow-up "move the map" request.
function wtShowPhotoLocationOnMap(){
const tab=wtTabs.find(t=>t.id===wtActiveTab);
if(!tab||tab.type!=='photo')return;
const m=tab.url.match(/satellite-(-?\d+\.?\d*)_(-?\d+\.?\d*)-/);
if(!m)return;
mapLat=parseFloat(m[1]);mapLon=parseFloat(m[2]);
const locInput=document.getElementById('map-loc');
if(locInput)locInput.value=`${mapLat},${mapLon}`;
switchCenterTab('map');
updateMap();
}
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){
const m=text.match(/https:\/\/embed\.windy\.com\/embed2\.html\?([a-zA-Z0-9.=&%_-]+)/);
if(!m)return false;
const url='https://embed.windy.com/embed2.html?'+m[1];
// Extract overlay from URL
const ovM=url.match(/overlay=([^&]+)/);
if(ovM)mapOverlay=ovM[1];
// Keep mapLat/mapLon/mapZoom/mapLevel/search-bar in sync so the move actually sticks
// (otherwise a reload snaps back to the old default, looking like it "undid" itself).
const latM=url.match(/[?&]lat=(-?[\d.]+)/),lonM=url.match(/[?&]lon=(-?[\d.]+)/),zoomM=url.match(/[?&]zoom=(-?[\d.]+)/);
if(latM&&lonM){
mapLat=parseFloat(latM[1]);mapLon=parseFloat(lonM[1]);
if(zoomM)mapZoom=parseFloat(zoomM[1]);
const locInput=document.getElementById('map-loc');
if(locInput)locInput.value=`${mapLat},${mapLon}`;
}
// e.g. level=250h for jet stream — keep it so a later zoom/location tweak (which
// goes through updateMap→windyUrl) doesn't silently fall back to surface wind.
const levelM=url.match(/[?&]level=([a-zA-Z0-9]+)/);
mapLevel=levelM?levelM[1]:'surface';
// wind+250h is exactly what the "제트기류" button also produces, so highlight that
// button instead of the plain "바람" one when the AI emits this combination.
const activeOv=(mapOverlay==='wind'&&mapLevel==='250h')?'jetstream':mapOverlay;
document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===activeOv));
setTyphoonMarker(/[?&]mark=typhoon(?:&|$)/.test(url));
wtLoadMap(url);
// A "move the map" reply is pointless if the user is still looking at a photo tab —
// switch them to the map tab so the move is actually visible, not just described in text.
switchCenterTab('map');
return true;
}
// Quick ask helpers
function updateQuickLocSel(){
const sel=document.getElementById('quick-loc-sel');
if(!sel)return;
const locs=getLocs();
const saved=localStorage.getItem('wt_quick_loc');
const presets=['서울','부산','대구','인천','광주','대전','울산','수원','구미','강릉','제주'];
const locNames=locs.map(l=>l.name);
const all=[...new Set([...locNames,...presets])];
sel.innerHTML=all.map(n=>`<option value="${n}"${n===(saved||locNames[0]||'서울')?'selected':''}>${n}</option>`).join('');
}
const currentLoc=()=>{
const sel=document.getElementById('quick-loc-sel');
if(sel&&sel.value)return sel.value;
const locs=getLocs();return locs[0]?.name||'서울';
};
function askWeather(type){
const loc=currentLoc();
const msgs={
today:`${loc} 오늘 날씨를 알려주세요. 기온, 강수 확률, 바람, 습도를 포함해주세요.`,
week:`${loc} 이번 주 주간 예보를 알려주세요.`,
air:`${loc} 현재 미세먼지(PM2.5, PM10)와 대기질 지수를 알려주세요.`,
typhoon:`현재 발생한 태풍이 있나요? 경로와 강도를 알려주세요.`,
rain:`${loc} 오늘과 내일 시간별 강수 확률과 예상 강수량을 알려주세요.`,
uv:`${loc} 오늘 자외선 지수와 시간대별 변화를 알려주세요.`,
climate:`${loc}의 월별 기후 평균(기온·강수량)을 30년 기준으로 알려주세요.`,
past:`${loc} 오늘 날짜 기준 역대 같은 날 날씨 기록을 알려주세요.`,
alert:`현재 ${loc} 및 전국 기상 특보(태풍·호우·폭염·한파·강풍·풍랑·황사)가 발효 중인지 알려주세요. 특보 종류, 대상 지역, 해제 예상 시간을 포함해주세요.`,
satellite:`요즘 지구에서 진행 중인 자연재해(태풍·산불·화산·지진 등)를 알려주고, 가장 주목할 만한 것 하나는 실제 위성사진으로 보여주세요.`,
};
sendToChat(msgs[type]||'날씨를 알려주세요.');
}
function fetchAlerts(){
document.getElementById('alert-content').textContent='💬 채팅창에서 확인하세요';
sendToChat('현재 한국 전국 기상 특보(태풍·호우·폭염·한파·강풍·황사 등)가 발효 중인지 알려주세요. 없으면 "현재 특보 없음"이라고만 답하세요.');
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='wt-msg '+role;
const b=document.createElement('div');b.className='wt-bubble';b.innerHTML=html;
// 수식은 DOM 삽입 후에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
div.appendChild(b);
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMdSafe(text){
// Extract Windy URLs before escaping
let windyLinks=[];
let out=text.replace(/https:\/\/embed\.windy\.com\/embed2\.html\?[a-zA-Z0-9.=&%_-]+/g,(url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`;
const ovM=url.match(/overlay=([^&]+)/);const ov=ovM?ovM[1]:'';
const label={satellite:'위성사진',radar:'레이더',rain:'강수',wind:'바람',temp:'기온',clouds:'구름',pm2p5:'대기질',pressure:'기압',snowcover:'적설',waves:'파고'}[ov]||'지도';
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url.replace(/'/g,'&#39;')}','${ov}')">🗺️ ${label} 지도 보기</span>`);
return ph;
});
// KMA typhoon forecast-track page — the one real "예상 진로" source, unlike Windy
out=out.replace(new RegExp(KMA_TYPHOON_URL.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'g'),(url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`;
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url}','kma-track')">🧭 예상진로 지도 보기</span>`);
return ph;
});
// JMA typhoon forecast-track map — covers Western Pacific storms outside KMA's own
// designated-storm scope (see JMA_TYPHOON_URL comment above).
out=out.replace(new RegExp(JMA_TYPHOON_URL.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'g'),(url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`;
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url}','jma-track')">🌏 JMA 예상진로 지도 보기</span>`);
return ph;
});
// NHC forecast-track page (non-Korean storms) — no fixed overlay button exists for this
// since the URL varies per storm/basin, so ov is omitted (no button to highlight active).
out=out.replace(new RegExp(NHC_TRACK_RE.source,'g'),(url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`;
windyLinks.push(`<span class="wt-map-link" onclick="loadWindyUrl('${url}')">🧭 예상진로 지도 보기(NHC)</span>`);
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
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<h3>)/g,'$1').replace(/(<\/h3>)<\/p>/g,'$1')
.replace(/<p>(<pre>)/g,'$1').replace(/(<\/pre>)<\/p>/g,'$1')
.replace(/<p>(<ul>)/g,'$1').replace(/(<\/ul>)<\/p>/g,'$1');
windyLinks.forEach((html,i)=>{out=out.replace(new RegExp(`\x00WINDY${i}\x00`,'g'),html);});
return out;
}
function loadNhcTrackUrl(text){
const m=text.match(NHC_TRACK_RE);
if(!m)return false;
loadWindyUrl(m[0]);
switchCenterTab('map');
return true;
}
function loadWindyUrl(url,ov){
if(ov){mapOverlay=ov;document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===ov));}
// No ov (e.g. an NHC track URL — no fixed button corresponds to it): clear any stale
// highlight instead of leaving a previous overlay looking active while showing this instead.
else document.querySelectorAll('.wt-ov').forEach(b=>b.classList.remove('active'));
const levelM=url.match(/[?&]level=([a-zA-Z0-9]+)/);
mapLevel=levelM?levelM[1]:'surface';
setTyphoonMarker(/[?&]mark=typhoon(?:&|$)/.test(url));
wtLoadMap(url);
}
function sendToChat(msg){if(window.innerWidth<=1024)mobToggle('chat');addMsg('user',escHtml(msg));callChat(msg);}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
if(window.innerWidth<=1024)mobToggle('chat');
input.value='';autoResize(input);
addMsg('user',escHtml(msg));
await callChat(msg);
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
const msgs=document.getElementById('chat-msgs');
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>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='',windyLoaded=false,satPhotoLoaded=false,kmaLoaded=false,jmaLoaded=false,nhcLoaded=false;
try{
const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='wt-msg assistant';
bubble=document.createElement('div');bubble.className='wt-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='final'&&typeof ev.text==='string'){
// The backend strips leaked/malformed tool-call JSON (e.g. a model typing
// "web_search{...}" as literal text instead of a real tool call) only from
// this post-hoc cleaned copy — the raw per-token stream above never sees that
// cleanup, so whatever leaked was already rendered. Overwrite with the clean
// version once it arrives so the bubble ends up correct instead of stuck showing
// the leak, and re-run the map/photo triggers in case the raw stream's version
// hid or mangled a URL that only appears intact in this cleaned text.
textBuf=ev.text;
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="wt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMdSafe(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
if(!windyLoaded&&processWindyUrl(textBuf))windyLoaded=true;
if(!satPhotoLoaded&&processSatelliteImage(textBuf))satPhotoLoaded=true;
if(!kmaLoaded&&textBuf.includes(KMA_TYPHOON_URL)){loadWindyUrl(KMA_TYPHOON_URL,'kma-track');switchCenterTab('map');kmaLoaded=true;}
if(!jmaLoaded&&textBuf.includes(JMA_TYPHOON_URL)){loadWindyUrl(JMA_TYPHOON_URL,'jma-track');switchCenterTab('map');jmaLoaded=true;}
if(!nhcLoaded&&loadNhcTrackUrl(textBuf))nhcLoaded=true;
}
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="wt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMdSafe(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
// Auto-load first Windy URL found. Wait until the match doesn't end exactly at
// the current buffer's end — otherwise the URL may still be mid-stream (e.g.
// "lat=13" before the rest of "13.5&zoom=...&overlay=..." arrives), and loading
// that truncated URL would set windyLoaded=true and lock out the real one.
if(!windyLoaded&&textBuf.includes('embed.windy.com')){
const wm=textBuf.match(/https:\/\/embed\.windy\.com\/embed2\.html\?[a-zA-Z0-9.=&%_-]+/);
if(wm&&wm.index+wm[0].length<textBuf.length&&processWindyUrl(textBuf)){windyLoaded=true;}
}
// Auto-open first satellite photo found
if(!satPhotoLoaded&&textBuf.includes('/api/files/satellite-images/')){
if(processSatelliteImage(textBuf)){satPhotoLoaded=true;}
}
// Auto-load KMA typhoon track page — fixed literal URL, so unlike the Windy
// case above there's no partial-number risk: substring presence IS completeness.
if(!kmaLoaded&&textBuf.includes(KMA_TYPHOON_URL)){
loadWindyUrl(KMA_TYPHOON_URL,'kma-track');
switchCenterTab('map');
kmaLoaded=true;
}
// Auto-load JMA typhoon track map — same fixed-URL boundary safety as KMA above.
if(!jmaLoaded&&textBuf.includes(JMA_TYPHOON_URL)){
loadWindyUrl(JMA_TYPHOON_URL,'jma-track');
switchCenterTab('map');
jmaLoaded=true;
}
// Auto-load NHC forecast-track page — variable-length URL (graphics_ep2 vs
// graphics_at12 etc.), so same boundary-safety as the Windy check above: only
// trust the match once something follows it, confirming it finished streaming.
if(!nhcLoaded&&textBuf.includes('nhc.noaa.gov/graphics_')){
const nm=textBuf.match(NHC_TRACK_RE);
if(nm&&nm.index+nm[0].length<textBuf.length&&loadNhcTrackUrl(textBuf)){nhcLoaded=true;}
}
}
}
}
if(!windyLoaded&&textBuf)processWindyUrl(textBuf);
if(!satPhotoLoaded&&textBuf)processSatelliteImage(textBuf);
if(!kmaLoaded&&textBuf.includes(KMA_TYPHOON_URL)){loadWindyUrl(KMA_TYPHOON_URL,'kma-track');switchCenterTab('map');}
if(!jmaLoaded&&textBuf.includes(JMA_TYPHOON_URL)){loadWindyUrl(JMA_TYPHOON_URL,'jma-track');switchCenterTab('map');}
if(!nhcLoaded&&textBuf)loadNhcTrackUrl(textBuf);
// The model is separately instructed to always ALSO include a plain Windy URL next to
// a KMA/JMA/NHC track link (so external-only answers can't skip the embed) — that means
// a single response can carry more than one, and whichever streamed later would silently
// win the final map state. A track-cone page is strictly more informative for a path
// question than the plain Windy view, so force it to be what's left showing if present —
// KMA (Korea's own authority) takes priority over JMA when a response somehow has both.
if(textBuf.includes(KMA_TYPHOON_URL)){loadWindyUrl(KMA_TYPHOON_URL,'kma-track');switchCenterTab('map');}
else if(textBuf.includes(JMA_TYPHOON_URL)){loadWindyUrl(JMA_TYPHOON_URL,'jma-track');switchCenterTab('map');}
else if(NHC_TRACK_RE.test(textBuf)){loadNhcTrackUrl(textBuf);}
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
}
async function clearChat(){
if(!confirm('이 컨텍스트의 대화를 지울까요?'))return;
const sid=getSessionId();
document.getElementById('chat-msgs').innerHTML='';
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();}}
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseAll(){
document.querySelector('.wt-sb').classList.remove('mob-open');
document.querySelector('.wt-chat').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['sb','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
document.getElementById('mobtab-map').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>1024)return;
const sbOpen=document.querySelector('.wt-sb').classList.contains('mob-open');
const chatOpen=document.querySelector('.wt-chat').classList.contains('mob-open');
if(which==='sb'&&sbOpen){mobCloseAll();return;}
if(which==='chat'&&chatOpen){mobCloseAll();return;}
mobCloseAll();
if(which==='sb'){
document.querySelector('.wt-sb').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-sb').classList.add('active');
document.getElementById('mobtab-map').classList.remove('active');
} else {
document.querySelector('.wt-chat').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-chat').classList.add('active');
document.getElementById('mobtab-map').classList.remove('active');
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
const ok=await checkAuth();if(!ok)return;
renderLocList();
restoreWtTabs();
renderCenterTabs();
// 지도는 인증 직후 바로 로드 (세션 ID와 무관)
wtLoadMap(windyUrl(mapLat,mapLon,mapZoom,mapOverlay));
await Promise.all([initSessionId(),loadSkill(),loadModels()]);
await ensureChatContextForLoc();
addMsg('system','🌤️ 날씨 어시스턴트입니다. 지역 날씨, 예보, 기상 현상을 질문하세요.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>