법률앱에서 `$\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>
1097 lines
80 KiB
HTML
1097 lines
80 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
|
||
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&¤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();
|
||
}
|
||
|
||
// ── 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,''')}','${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 ) — extracted
|
||
// before escaping/paragraph-wrapping like the Windy links above, same reason this
|
||
// renderer never needed image support until the satellite feature.
|
||
out=out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g,(_m,alt,url)=>{
|
||
const ph=`\x00WINDY${windyLinks.length}\x00`; // shares windyLinks' placeholder/restore mechanism below
|
||
windyLinks.push(`<img src="${url.replace(/"/g,'"')}" alt="${alt.replace(/"/g,'"')}" style="max-width:100%;border-radius:8px;margin:6px 0;display:block">`);
|
||
return ph;
|
||
});
|
||
out=out
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.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>
|