Files
homeclaw/web-ui/weather-app.html
T
kimandClaude Sonnet 5 1f112fd227 v4.1.2: 기상도(Windy) 캡처 화질 개선 + 지도 읽는 법·좌표·태풍 트리거 추가
- weather_map_screenshot 캡처 해상도 900x620→1280x880, PNG→JPEG(품질90)로 변경.
  300KB 넘으면 vision 파이프라인이 600x600으로 강제 축소하던 걸(server-v2.ts
  resolveToolImageContent) 파일 크기를 낮춰서 회피 — 지명·기온 숫자가 선명해짐
- 기상전문가 프롬프트에 lat/lon을 서울로 고정 복사하지 않고 실제 언급된 지역
  기준으로 치환하도록 명시 + 주요 도시 12곳 좌표표 추가
- 오버레이 9종(바람/강수/기온/구름/기압/대기질/레이더/적설/파고)별 색상 범례
  해석 가이드("지도 읽는 법") 추가 — 이미지는 받아도 읽는 법이 없어 해석이
  부실했던 문제
- "태풍" 계열 단어를 Windy 지도 트리거에 추가(기존엔 없어서 뜨는 게 랜덤했음)
  + 태풍 요청 시 좌표를 먼저 검색해 그 위치로 지도를 센터링하도록 지시

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:47:43 +09:00

727 lines
50 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="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_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;}
/* 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-map-frame{width:100%;height:100%;border:none;display:block;}
.wt-map-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;background:var(--bg);pointer-events:none;transition:opacity .3s;}
.wt-map-loading.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>
<script src="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>
</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 ── -->
<section class="wt-center">
<div class="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="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="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>
</div>
</div>
<div class="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" onload="document.getElementById('map-loading').classList.add('hidden')"></iframe>
</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('cherryclaw_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);
for(const m of recent){
if(m.role==='user')addMsg('user',escHtml(m.content||''));
else addMsg('assistant',renderMdSafe(m.content||''));
}
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트리거 단어가 있을 때: `https://embed.windy.com/embed2.html?lat=LAT&lon=LON&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` 형식으로 LAT·LON·OVERLAY·ZOOM을 맥락에 맞게 채워 **독립된 한 줄**로 출력하세요.\n\n**LAT·LON은 항상 실제로 묻는 지역 기준으로 바꾸세요** — 서울(37.5, 126.9)을 기본값처럼 고정해서 복사하지 마세요. 지역이 특정되지 않았다면 사용자의 기본 지역(서울)을 쓰세요.\n\n**주요 지역 좌표**: 서울(37.5,126.9) 부산(35.18,129.08) 인천(37.46,126.7) 대구(35.87,128.6) 광주(35.16,126.85) 대전(36.35,127.38) 제주(33.5,126.53) 강릉(37.75,128.9) 평양(39.02,125.75) 도쿄(35.68,139.69) 베이징(39.9,116.4) 상하이(31.23,121.47) — 목록에 없는 지역은 알고 있는 좌표를 사용하세요.\n\n**중요**: URL을 출력한 직후 반드시 `weather_map_screenshot` 도구를 그 URL 그대로 호출해서 실제로 렌더링된 지도를 확인하세요. 캡처한 이미지를 보기 전에는 강수·구름·레이더 패턴을 절대 추측하지 마세요 — 이미지에 실제로 보이는 것만 근거로 설명하세요.\n\n**태풍 위치/경로 요청 시**: lat·lon을 서울이나 기본 지역이 아니라 **태풍의 실제 현재 위치**로 맞추세요. 순서: ① `web_search` 또는 `weather_search`로 태풍의 현재 좌표(위도·경도)를 먼저 확인 → ② 그 좌표를 lat·lon에 대입 → ③ zoom은 태풍 전체 규모가 보이도록 4~5(넓게) 사용 → ④ overlay는 `wind`(바람 강도로 태풍 눈·나선구조 확인) 또는 `rain`을 우선 사용. 태풍 좌표를 모른 채 임의 지역으로 지도를 띄우지 마세요.\n\noverlay: `rain`(강수) `wind`(바람) `temp`(기온) `clouds`(구름) `pressure`(기압) `pm2p5`(대기질) `radar`(레이더) `snowcover`(적설) `waves`(파고)\n\nzoom: 5=한반도 전체, 7=수도권, 10=시내\n\n**지도 읽는 법** (오버레이별 색상 해석 — 색상만으로 정확한 수치를 추측하지 말고, 화면에 찍힌 숫자·범례만 근거로 삼으세요):\n- `wind`(바람): 흰색 흐름선이 바람 방향·세기, 하단 색상바(kt 단위)가 풍속 범례. 파랑→초록→노랑→주황→빨강/보라 순으로 강해짐\n- `rain`/`radar`(강수/레이더): 색이 진할수록 강수강도가 강함. 연파랑(약한 비)→초록→노랑→빨강→보라(매우 강함). radar는 예보가 아닌 실시간 관측값\n- `temp`(기온): 파랑(추움)→초록/노랑(온화)→주황/빨강(더움). 도시 위 숫자는 실제 기온 값이므로 그대로 인용 가능\n- `clouds`(구름): 흰색·회색이 진할수록 구름이 두꺼움(구름량 많음), 투명하면 맑음\n- `pressure`(기압): 등압선(같은 기압을 잇는 곡선)과 H(고기압)·L(저기압) 표시. 등압선 간격이 좁을수록 바람이 강함\n- `pm2p5`(대기질): 초록(좋음)→노랑(보통)→주황/빨강(나쁨)→보라/적갈색(매우나쁨)\n- `snowcover`(적설): 흰색·연파랑(적음)→진파랑/보라(많음)\n- `waves`(파고): 파랑(잔잔)→초록/노랑(보통)→주황/빨강(높음·위험). 화살표는 파도 진행 방향\n- 좌하단 타임라인은 지도가 보여주는 시각(날짜·시간)을 나타냄 — 반드시 확인하고 그 시점 기준으로 설명\n\n---\n\n### 도구\n\n| 도구 | 용도 |\n|---|---|\n| `weather_kma` | 한국 날씨 (초단기실황·단기예보, 정확도 최우선) |\n| `weather_search` | 해외 날씨·5일 예보 (OpenWeather) |\n| `weather_openmeteo` | 시간별 상세 예보·UV·강수확률, 최대 16일 |\n| `weather_airkorea` | 한국 실시간 대기질 (PM2.5/PM10/O3) |\n| `weather_airpollution` | 해외 도시 대기질 |\n| `weather_era5` | 과거 날씨 조회 (1940년~5일 전) |\n| `weather_nasa_power` | 30년 기후 평균·월별·일별 (1981년~현재) |\n| `weather_cmip6` | 미래 기후 추이 (1950~2050) |\n| `weather_cds` | SSP 시나리오 비교·압력면 데이터 |\n| `web_search` | 태풍 경로, 기상 특보, 기후 뉴스 |\n| `python_eval` | 체감온도·불쾌지수·이슬점 계산 |\n| `weather_map_screenshot` | Windy 지도를 실제로 캡처해서 확인 (URL 출력 직후 반드시 호출) |\n| `image_read` | 사용자가 업로드한 이미지 분석(OCR) |\n\n**도구 선택**: 한국 날씨→`weather_kma` / 해외→`weather_search` / 시간별 상세→`weather_openmeteo` / 한국 대기질→`weather_airkorea` / 과거→`weather_era5` / 기후 평균→`weather_nasa_power` / 미래→`weather_cmip6` / 뉴스·특보→`web_search`\n\n**`weather_nasa_power` 변수**: `T2M`(기온) `T2M_MAX/MIN`(최고/최저) `PRECTOTCORR`(강수량) `RH2M`(습도) `WS2M`(풍속) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(기압) `T2MDEW`(이슬점) | temporal: `climatology`(30년) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` 변수**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: 연산자**: 한국 날씨→`weather.go.kr` / 태풍→`typ.kma.go.kr` / ECMWF→`ecmwf.int` / 허리케인→`nhc.noaa.gov` / 기후→`climate.go.kr` / 레이더→`radar.weather.go.kr` / 대기질→`airkorea.or.kr`\n\n---\n\n### 기상 현상 기준치\n\n| 현상 | 기준 |\n|---|---|\n| 집중호우 | 1h 30mm↑ 또는 12h 80mm↑ |\n| 태풍 | 최대풍속 17m/s↑ (강풍: 10분 평균 14m/s↑) |\n| 폭염 | 일최고 35℃↑ 2일↑ (주의보: 33℃↑), 열대야: 최저 25℃↑ |\n| 한파 | 전날 대비 10℃↑ 하강, 최저 -12℃↓ |\n| 황사 | PM2.5 나쁨 36μg/m³↑, 매우나쁨 76μg/m³↑ |\n\n**기상 특보**:\n\n| 특보 | 주의보 | 경보 |\n|---|---|---|\n| 강풍 | 평균 14m/s 또는 순간 20m/s | 평균 21m/s 또는 순간 26m/s |\n| 호우 | 3h 60mm 또는 12h 110mm | 3h 90mm 또는 12h 180mm |\n| 대설 | 24h 5cm↑ | 24h 20cm↑ |\n| 폭염 | 최고 33℃↑ 2일↑ | 최고 35℃↑ 2일↑ |\n| 한파 | -12℃↓ | -15℃↓ |\n\n**위험 기상 행동 요령**: 태풍·강풍→낙하물 실내 이동, 해안 금지 / 집중호우→지하공간 대피, 하천 접근 금지 / 폭염→낮 12~17시 야외 최소화, 수분 1.5L↑ / 한파→방한복, 수도관 동파 방지 / 대설→스노체인, 미끄럼 주의\n\n**체감온도·불쾌지수 계산** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=℃, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=℃, RH=%\n```\n\n---\n\n**응답 원칙**: 수치 구체적으로(\"강수 확률 70%, 오후 2시경부터\"), 표·목록으로 가독성, 3일 이후는 불확실성 명시, 실용 조언 포함(\"우산 챙기세요\").\n\n이 서비스는 기상 정보 해설 목적이며, 중요한 결정은 기상청(weather.go.kr) 최신 예보를 기준으로 하세요.\n";
function loadSkill(){
skillContext=SKILL_CONTENT;
document.getElementById('skill-badge').textContent='🌤️ 기상 전문가 활성';
}
// ── 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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='radar';
function windyUrl(lat,lon,zoom,overlay){
return `https://embed.windy.com/embed2.html?lat=${lat}&lon=${lon}&zoom=${zoom}&level=surface&overlay=${overlay}&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C`;
}
function updateMap(){
const zoom=parseInt(document.getElementById('map-zoom').value||'6',10);
mapZoom=zoom;
document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=windyUrl(mapLat,mapLon,mapZoom,mapOverlay);
}
function setOverlay(ov){
mapOverlay=ov;
document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===ov));
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
function processWindyUrl(text){
const m=text.match(/https:\/\/embed\.windy\.com\/embed2\.html\?([^\s"'<>]+)/);
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];
document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===mapOverlay));
}
document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=url;
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} 및 전국 기상 특보(태풍·호우·폭염·한파·강풍·풍랑·황사)가 발효 중인지 알려주세요. 특보 종류, 대상 지역, 해제 예상 시간을 포함해주세요.`,
};
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;
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\?[^\s"'<>\n]+/g,(url)=>{
const ph=`\x00WINDY${windyLinks.length}\x00`;
const ovM=url.match(/overlay=([^&]+)/);const ov=ovM?ovM[1]:'';
const label={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;
});
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 loadWindyUrl(url,ov){
if(ov){mapOverlay=ov;document.querySelectorAll('.wt-ov').forEach(b=>b.classList.toggle('active',b.dataset.ov===ov));}
document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=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;
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==='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
if(!windyLoaded&&textBuf.includes('embed.windy.com')){
if(processWindyUrl(textBuf)){windyLoaded=true;}
}
}
}
}
if(!windyLoaded&&textBuf)processWindyUrl(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();
// 지도는 인증 직후 바로 로드 (세션 ID와 무관)
document.getElementById('map-loading').classList.remove('hidden');
document.getElementById('windy-frame').src=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>