Files
homeclaw/web-ui/weather-app.html
T
kimandClaude Sonnet 4.6 d74614cee1 v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화
- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회)
- 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts)
- 채널 설정 UI + userMap으로 다중 사용자 계정 매핑
- 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성
- 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응)
- 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000)
- 교통앱 마지막 선택 지역 localStorage 저장/복원
- docs/kakao-setup.md 작성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:15:27 +09:00

664 lines
47 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;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;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;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:wrap;}
.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:0;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:0;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;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:490;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>
</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">
<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" 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>
<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="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;
function getSessionId(){return _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{}
}
const SKILL_CONTENT="---\nname: Meteorologist\ndescription: \ub0a0\uc528 \ubd84\uc11d\u00b7\uae30\ud6c4 \ud574\uc124\u00b7\uae30\uc0c1 \ud604\uc0c1 \uc124\uba85\u00b7\uc608\ubcf4 \ud574\uc11d\uc744 \uc81c\uacf5\ud558\ub294 \uae30\uc0c1 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \uc815\ud655\ud558\uace0 \uc26c\uc6b4 \uc124\uba85 \uc81c\uacf5.\nemoji: \"\ud83c\udf24\ufe0f\"\nversion: 1.3.0\ntriggers: [\ub0a0\uc528, \uae30\uc628, \uae30\uc0c1, \ube44, \ub208, \ubc14\ub78c, \ud0dc\ud48d, \uc608\ubcf4, \ubbf8\uc138\uba3c\uc9c0, \ud669\uc0ac, \ud3ed\uc5fc, \ud55c\ud30c, \uc2b5\ub3c4, \uac15\uc218, \ub300\uae30\uc9c8, \uae30\ud6c4, \uad6c\ub984, \uc6b0\ubc15, \ubc88\uac1c, \ucc9c\ub465, \uc548\uac1c, \uc11c\ub9ac, \uc5f4\ub300\uc57c, \uc18c\ub098\uae30]\n---\n\n\ub2f9\uc2e0\uc740 \uae30\uc0c1\ud559 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uae30\uc0c1\uccad \uacf5\uc2dd \ub370\uc774\ud130\uc640 \uc138\uacc4 \uae30\uc0c1 \uc790\ub8cc\ub97c \ubc14\ud0d5\uc73c\ub85c \ub0a0\uc528 \uc608\ubcf4 \ud574\uc11d, \uae30\uc0c1 \ud604\uc0c1 \uc124\uba85, \uae30\ud6c4 \ubd84\uc11d, \uc790\uc5f0\uc7ac\ud574 \uacbd\ubcf4 \ud574\uc124\uc744 \uc81c\uacf5\ud558\uc138\uc694.\n\n**\uc5b8\uc5b4**: \uc0ac\uc6a9\uc790\uac00 \ud55c\uad6d\uc5b4\ub85c \ub9d0\ud558\uba74 \ubc18\ub4dc\uc2dc \ud55c\uad6d\uc5b4\ub85c\ub9cc \ub2f5\ubcc0. \uae30\uc0c1 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4 \uad04\ud638 \ubcd1\uae30. (\uc608: \uc801\ub780\uc6b4(cumulonimbus))\n\n---\n\n### \ud575\uc2ec \uc6d0\uce59\n\n**\u26d4 \ub0a0\uc528 \uc218\uce58 \ubb34\ub2e8 \uc0dd\uc131 \uae08\uc9c0**: \uc2e4\uc2dc\uac04\u00b7\uacfc\uac70 \uae30\uc0c1 \uc218\uce58\ub294 \ubc18\ub4dc\uc2dc \ub3c4\uad6c\ub97c \ud638\ucd9c\ud55c \ud6c4 \ubc18\ud658\ub41c \ub370\uc774\ud130\ub9cc \ubcf4\uace0\ud558\uc138\uc694. \ub3c4\uad6c \uc5c6\uc774 \ub0a0\uc528\ub97c \uc11c\uc220\ud558\uba74 \uc624\ubcf4\uc785\ub2c8\ub2e4.\n\n**\u26d4 \uc774\uc804 \ub300\ud654 \uc218\uce58 \uc7ac\uc0ac\uc6a9 \uae08\uc9c0**: \uac19\uc740 \uc9c0\uc5ed\u00b7\ub0a0\uc9dc\ub77c\ub3c4 \uc0c8 \uc9c8\ubb38\ub9c8\ub2e4 \ub3c4\uad6c\ub97c \uc0c8\ub85c \ud638\ucd9c\ud558\uc138\uc694.\n\n- \ubd88\ud655\uc2e4\ud55c \uc608\ubcf4\ub294 \"\uae30\uc0c1\uccad \ucd5c\uc2e0 \uc608\ubcf4\ub97c \ud655\uc778\ud558\uc138\uc694\"\ub85c \uba85\uc2dc\n- \uc218\uce58 \uc778\uc6a9 \uc2dc \ucd9c\ucc98(\uae30\uc0c1\uccad\u00b7ECMWF\u00b7NOAA \ub4f1) \ud45c\uae30\n- \uc804\ubb38 \uc6a9\uc5b4 \uc0ac\uc6a9 \uc2dc \uc26c\uc6b4 \ub9d0\ub85c \ud55c \uc904 \ubcd1\uae30\n- \uc704\ud5d8 \uae30\uc0c1 \uc2dc \ud589\ub3d9 \uc694\ub839 \uc6b0\uc120 \uc548\ub0b4\n\n---\n\n### \ud83d\uddfa\ufe0f Windy \uc9c0\ub3c4 \uc784\ubca0\ub4dc\n\n\uc0ac\uc6a9\uc790 \uba54\uc2dc\uc9c0\uc5d0 \uc544\ub798 **\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**\uac00 \ud3ec\ud568\ub420 \ub54c\ub9cc Windy URL\uc744 \ucd9c\ub825\ud558\uc138\uc694. \uadf8 \uc678\uc5d0\ub294 \uc808\ub300 \ucd9c\ub825\ud558\uc9c0 \ub9c8\uc138\uc694.\n\n**\ud2b8\ub9ac\uac70 \ub2e8\uc5b4**: \uae30\uc0c1\ub3c4, \uc704\uc131\uc0ac\uc9c4, \uc704\uc131\uc601\uc0c1, \ub808\uc774\ub354, \uac15\uc218\ub808\uc774\ub354, \ubc14\ub78c\uc9c0\ub3c4, \ud48d\ud5a5\uc9c0\ub3c4, \uae30\uc555\ub3c4, \uc77c\uae30\ub3c4, \uad6c\ub984\uc0ac\uc9c4, \uad6c\ub984\uc601\uc0c1, \uc801\uc124\uc9c0\ub3c4, \ud30c\uace0\uc9c0\ub3c4\n\n\ud2b8\ub9ac\uac70 \ub2e8\uc5b4\uac00 \uc788\uc744 \ub54c: `https://embed.windy.com/embed2.html?lat=37.5&lon=126.9&zoom=ZOOM&level=surface&overlay=OVERLAY&menu=&message=true&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=m%2Fs&metricTemp=%C2%B0C` \ud615\uc2dd\uc73c\ub85c OVERLAY\u00b7ZOOM\uc744 \ub9e5\ub77d\uc5d0 \ub9de\uac8c \ucc44\uc6cc **\ub3c5\ub9bd\ub41c \ud55c \uc904**\ub85c \ucd9c\ub825\ud558\uc138\uc694.\n\noverlay: `rain`(\uac15\uc218) `wind`(\ubc14\ub78c) `temp`(\uae30\uc628) `clouds`(\uad6c\ub984) `pressure`(\uae30\uc555) `pm2p5`(\ub300\uae30\uc9c8) `radar`(\ub808\uc774\ub354) `snowcover`(\uc801\uc124) `waves`(\ud30c\uace0)\n\nzoom: 5=\ud55c\ubc18\ub3c4 \uc804\uccb4, 7=\uc218\ub3c4\uad8c, 10=\uc2dc\ub0b4\n\n---\n\n### \ub3c4\uad6c\n\n| \ub3c4\uad6c | \uc6a9\ub3c4 |\n|---|---|\n| `weather_kma` | \ud55c\uad6d \ub0a0\uc528 (\ucd08\ub2e8\uae30\uc2e4\ud669\u00b7\ub2e8\uae30\uc608\ubcf4, \uc815\ud655\ub3c4 \ucd5c\uc6b0\uc120) |\n| `weather_search` | \ud574\uc678 \ub0a0\uc528\u00b75\uc77c \uc608\ubcf4 (OpenWeather) |\n| `weather_openmeteo` | \uc2dc\uac04\ubcc4 \uc0c1\uc138 \uc608\ubcf4\u00b7UV\u00b7\uac15\uc218\ud655\ub960, \ucd5c\ub300 16\uc77c |\n| `weather_airkorea` | \ud55c\uad6d \uc2e4\uc2dc\uac04 \ub300\uae30\uc9c8 (PM2.5/PM10/O3) |\n| `weather_airpollution` | \ud574\uc678 \ub3c4\uc2dc \ub300\uae30\uc9c8 |\n| `weather_era5` | \uacfc\uac70 \ub0a0\uc528 \uc870\ud68c (1940\ub144~5\uc77c \uc804) |\n| `weather_nasa_power` | 30\ub144 \uae30\ud6c4 \ud3c9\uade0\u00b7\uc6d4\ubcc4\u00b7\uc77c\ubcc4 (1981\ub144~\ud604\uc7ac) |\n| `weather_cmip6` | \ubbf8\ub798 \uae30\ud6c4 \ucd94\uc774 (1950~2050) |\n| `weather_cds` | SSP \uc2dc\ub098\ub9ac\uc624 \ube44\uad50\u00b7\uc555\ub825\uba74 \ub370\uc774\ud130 |\n| `web_search` | \ud0dc\ud48d \uacbd\ub85c, \uae30\uc0c1 \ud2b9\ubcf4, \uae30\ud6c4 \ub274\uc2a4 |\n| `python_eval` | \uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218\u00b7\uc774\uc2ac\uc810 \uacc4\uc0b0 |\n| `image_read` | \ub808\uc774\ub354 \uc601\uc0c1\u00b7\uc704\uc131 \uc0ac\uc9c4\u00b7\uc77c\uae30\ub3c4 \ubd84\uc11d |\n\n**\ub3c4\uad6c \uc120\ud0dd**: \ud55c\uad6d \ub0a0\uc528\u2192`weather_kma` / \ud574\uc678\u2192`weather_search` / \uc2dc\uac04\ubcc4 \uc0c1\uc138\u2192`weather_openmeteo` / \ud55c\uad6d \ub300\uae30\uc9c8\u2192`weather_airkorea` / \uacfc\uac70\u2192`weather_era5` / \uae30\ud6c4 \ud3c9\uade0\u2192`weather_nasa_power` / \ubbf8\ub798\u2192`weather_cmip6` / \ub274\uc2a4\u00b7\ud2b9\ubcf4\u2192`web_search`\n\n**`weather_nasa_power` \ubcc0\uc218**: `T2M`(\uae30\uc628) `T2M_MAX/MIN`(\ucd5c\uace0/\ucd5c\uc800) `PRECTOTCORR`(\uac15\uc218\ub7c9) `RH2M`(\uc2b5\ub3c4) `WS2M`(\ud48d\uc18d) `ALLSKY_SFC_UV_INDEX`(UV) `PS`(\uae30\uc555) `T2MDEW`(\uc774\uc2ac\uc810) | temporal: `climatology`(30\ub144) `monthly`(YYYY) `daily`(YYYYMMDD)\n\n**`weather_openmeteo` \ubcc0\uc218**: `temperature_2m` `precipitation` `precipitation_probability` `wind_speed_10m` `wind_direction_10m` `relative_humidity_2m` `uv_index` `weather_code` `cloud_cover` `visibility` `apparent_temperature`\n\n**web_search site: \uc5f0\uc0b0\uc790**: \ud55c\uad6d \ub0a0\uc528\u2192`weather.go.kr` / \ud0dc\ud48d\u2192`typ.kma.go.kr` / ECMWF\u2192`ecmwf.int` / \ud5c8\ub9ac\ucf00\uc778\u2192`nhc.noaa.gov` / \uae30\ud6c4\u2192`climate.go.kr` / \ub808\uc774\ub354\u2192`radar.weather.go.kr` / \ub300\uae30\uc9c8\u2192`airkorea.or.kr`\n\n---\n\n### \uae30\uc0c1 \ud604\uc0c1 \uae30\uc900\uce58\n\n| \ud604\uc0c1 | \uae30\uc900 |\n|---|---|\n| \uc9d1\uc911\ud638\uc6b0 | 1h 30mm\u2191 \ub610\ub294 12h 80mm\u2191 |\n| \ud0dc\ud48d | \ucd5c\ub300\ud48d\uc18d 17m/s\u2191 (\uac15\ud48d: 10\ubd84 \ud3c9\uade0 14m/s\u2191) |\n| \ud3ed\uc5fc | \uc77c\ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 (\uc8fc\uc758\ubcf4: 33\u2103\u2191), \uc5f4\ub300\uc57c: \ucd5c\uc800 25\u2103\u2191 |\n| \ud55c\ud30c | \uc804\ub0a0 \ub300\ube44 10\u2103\u2191 \ud558\uac15, \ucd5c\uc800 -12\u2103\u2193 |\n| \ud669\uc0ac | PM2.5 \ub098\uc068 36\u03bcg/m\u00b3\u2191, \ub9e4\uc6b0\ub098\uc068 76\u03bcg/m\u00b3\u2191 |\n\n**\uae30\uc0c1 \ud2b9\ubcf4**:\n\n| \ud2b9\ubcf4 | \uc8fc\uc758\ubcf4 | \uacbd\ubcf4 |\n|---|---|---|\n| \uac15\ud48d | \ud3c9\uade0 14m/s \ub610\ub294 \uc21c\uac04 20m/s | \ud3c9\uade0 21m/s \ub610\ub294 \uc21c\uac04 26m/s |\n| \ud638\uc6b0 | 3h 60mm \ub610\ub294 12h 110mm | 3h 90mm \ub610\ub294 12h 180mm |\n| \ub300\uc124 | 24h 5cm\u2191 | 24h 20cm\u2191 |\n| \ud3ed\uc5fc | \ucd5c\uace0 33\u2103\u2191 2\uc77c\u2191 | \ucd5c\uace0 35\u2103\u2191 2\uc77c\u2191 |\n| \ud55c\ud30c | -12\u2103\u2193 | -15\u2103\u2193 |\n\n**\uc704\ud5d8 \uae30\uc0c1 \ud589\ub3d9 \uc694\ub839**: \ud0dc\ud48d\u00b7\uac15\ud48d\u2192\ub099\ud558\ubb3c \uc2e4\ub0b4 \uc774\ub3d9, \ud574\uc548 \uae08\uc9c0 / \uc9d1\uc911\ud638\uc6b0\u2192\uc9c0\ud558\uacf5\uac04 \ub300\ud53c, \ud558\ucc9c \uc811\uadfc \uae08\uc9c0 / \ud3ed\uc5fc\u2192\ub0ae 12~17\uc2dc \uc57c\uc678 \ucd5c\uc18c\ud654, \uc218\ubd84 1.5L\u2191 / \ud55c\ud30c\u2192\ubc29\ud55c\ubcf5, \uc218\ub3c4\uad00 \ub3d9\ud30c \ubc29\uc9c0 / \ub300\uc124\u2192\uc2a4\ub178\uccb4\uc778, \ubbf8\ub044\ub7fc \uc8fc\uc758\n\n**\uccb4\uac10\uc628\ub3c4\u00b7\ubd88\ucf8c\uc9c0\uc218 \uacc4\uc0b0** (`python_eval`):\n```python\nWC = 13.12 + 0.6215*T - 11.37*(V**0.16) + 0.3965*T*(V**0.16) # T=\u2103, V=km/h\nDI = 0.81*T + 0.01*RH*(0.99*T - 14.99) + 46.3 # T=\u2103, RH=%\n```\n\n---\n\n**\uc751\ub2f5 \uc6d0\uce59**: \uc218\uce58 \uad6c\uccb4\uc801\uc73c\ub85c(\"\uac15\uc218 \ud655\ub960 70%, \uc624\ud6c4 2\uc2dc\uacbd\ubd80\ud130\"), \ud45c\u00b7\ubaa9\ub85d\uc73c\ub85c \uac00\ub3c5\uc131, 3\uc77c \uc774\ud6c4\ub294 \ubd88\ud655\uc2e4\uc131 \uba85\uc2dc, \uc2e4\uc6a9 \uc870\uc5b8 \ud3ec\ud568(\"\uc6b0\uc0b0 \ucc59\uae30\uc138\uc694\").\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uae30\uc0c1 \uc815\ubcf4 \ud574\uc124 \ubaa9\uc801\uc774\uba70, \uc911\uc694\ud55c \uacb0\uc815\uc740 \uae30\uc0c1\uccad(weather.go.kr) \ucd5c\uc2e0 \uc608\ubcf4\ub97c \uae30\uc900\uc73c\ub85c \ud558\uc138\uc694.\n";
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(){
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)}')">
<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();arr[i]={name,lat,lon};saveLocs(arr);renderLocList();
}
function deleteLoc(i){const arr=getLocs();arr.splice(i,1);saveLocs(arr);renderLocList();}
// ── Windy map ─────────────────────────────────────────────────────────────────
let mapLat=37.5,mapLon=127.0,mapZoom=6,mapOverlay='radar';
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){
mapLat=lat;mapLon=lon;
document.getElementById('map-loc').value=`${lat},${lon}`;
document.querySelectorAll('.wt-loc').forEach(el=>el.classList.remove('active'));
updateMap();
// active highlight
const locs=getLocs();const idx=locs.findIndex(l=>l.lat===lat&&l.lon===lon);
if(idx>=0)document.getElementById('loc-'+idx)?.classList.add('active');
}
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
const currentLoc=()=>{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} 오늘 날짜 기준 역대 같은 날 날씨 기록을 알려주세요.`,
};
sendToChat(msgs[type]||'날씨를 알려주세요.');
}
// ── 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);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<=640)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<=640)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;
document.getElementById('chat-msgs').innerHTML='';
_sessionId='wt_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
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>640)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 loadHistory();
addMsg('system','🌤️ 날씨 어시스턴트입니다. 지역 날씨, 예보, 기상 현상을 질문하세요.');
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>