- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 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>
664 lines
47 KiB
HTML
664 lines
47 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;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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
|
||
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,''')}','${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<=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>
|