- 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>
727 lines
50 KiB
HTML
727 lines
50 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="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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
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='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,''')}','${ov}')">🗺️ ${label} 지도 보기</span>`);
|
||
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 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>
|