- 투자앱 시작 시 빈 차트 대신 XLK(기술주 섹터 ETF) TradingView 차트를 바로 띄우도록 변경 - 언어앱 플래시카드 타일 그리드 뷰에 🔊 발음 듣기 버튼 추가 (기존엔 로마자 발음 표시만 있고 TTS 버튼이 빠져있었음) — 단일 카드 뷰와 동일하게 카드 중앙 하단에 배치, 클릭 시 카드가 뒤집히지 않도록 stopPropagation 처리 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
852 lines
60 KiB
HTML
852 lines
60 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>
|
||
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 ── */
|
||
.iv-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.iv-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||
.iv-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:#22c55e;color:#22c55e;}
|
||
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.25);color:#4ade80;font-weight:700;}
|
||
|
||
/* ── Layout ── */
|
||
.iv-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
|
||
/* ── Sidebar ── */
|
||
.iv-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;}
|
||
.iv-sb::-webkit-scrollbar{width:3px;}
|
||
.iv-sb::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.iv-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;flex-shrink:0;display:flex;align-items:center;}
|
||
.iv-sb-sec span{flex:1;}
|
||
.iv-add-btn{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:0 4px;transition:.12s;line-height:1;}
|
||
.iv-add-btn:hover{color:#22c55e;}
|
||
.iv-group{padding:0 6px 4px;}
|
||
.iv-ticker{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;transition:.12s;border:1px solid transparent;}
|
||
.iv-ticker:hover{background:rgba(34,197,94,.05);border-color:rgba(34,197,94,.15);}
|
||
.iv-ticker:hover .iv-t-actions{opacity:1;}
|
||
.iv-ticker.active{background:rgba(34,197,94,.08);border-color:rgba(34,197,94,.25);}
|
||
.iv-t-info{flex:1;min-width:0;overflow:hidden;}
|
||
.iv-t-code{font-size:11px;font-weight:700;color:var(--text);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.iv-t-name{font-size:10px;color:var(--muted);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.iv-ticker.active .iv-t-name{color:#4ade80;}
|
||
.iv-t-actions{display:flex;gap:1px;opacity:0;transition:.12s;flex-shrink:0;}
|
||
.iv-t-act{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:2px 3px;border-radius:3px;transition:.12s;line-height:1;}
|
||
.iv-t-act:hover{color:#22c55e;background:rgba(34,197,94,.1);}
|
||
.iv-t-act.del:hover{color:#ef4444;background:rgba(239,68,68,.1);}
|
||
|
||
/* ── Inline add/edit form ── */
|
||
.iv-ticker-form{padding:5px 6px;border:1px solid rgba(34,197,94,.3);border-radius:7px;background:rgba(34,197,94,.04);margin:2px 6px;}
|
||
.iv-tf-row{display:flex;gap:4px;margin-bottom:4px;align-items:center;}
|
||
.iv-tf-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;}
|
||
.iv-tf-input:focus{border-color:#22c55e;}
|
||
.iv-tf-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:10px;color:var(--text);font-family:inherit;}
|
||
.iv-tf-btns{display:flex;gap:4px;}
|
||
.iv-tf-save{background:#14532d;border:none;border-radius:5px;padding:3px 9px;font-size:11px;font-weight:700;color:#fff;cursor:pointer;font-family:inherit;}
|
||
.iv-tf-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;}
|
||
|
||
.iv-divider{height:1px;background:var(--line);margin:4px 6px;}
|
||
.iv-quick-section{padding:0 6px 6px;}
|
||
.iv-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 8px;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;}
|
||
.iv-qbtn:hover{border-color:#22c55e;color:#22c55e;background:rgba(34,197,94,.04);}
|
||
.iv-disclaimer{margin:6px;padding:7px 9px;background:rgba(234,179,8,.05);border:1px solid rgba(234,179,8,.15);border-radius:7px;font-size:10px;color:var(--muted);line-height:1.6;flex-shrink:0;}
|
||
|
||
/* ── Center ── */
|
||
.iv-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;}
|
||
|
||
/* Chart search bar */
|
||
.iv-chart-bar{padding:7px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
|
||
.iv-chart-type{display:flex;gap:0;border:1px solid var(--line);border-radius:7px;overflow:hidden;flex-shrink:0;}
|
||
.iv-ct-btn{padding:4px 11px;font-size:11px;font-weight:700;cursor:pointer;background:none;border:none;color:var(--muted);font-family:inherit;transition:.12s;}
|
||
.iv-ct-btn.active{background:#052e16;color:#4ade80;}
|
||
[data-theme="light"] .iv-ct-btn.active{background:#dcfce7;color:#15803d;}
|
||
.iv-sym-input{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;width:120px;transition:.15s;}
|
||
.iv-sym-input:focus{border-color:#22c55e;}
|
||
.iv-period-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;}
|
||
.iv-load-btn{background:#14532d;border:none;border-radius:7px;padding:5px 13px;font-size:12px;font-weight:700;color:#fff;cursor:pointer;font-family:inherit;transition:.15s;white-space:nowrap;}
|
||
.iv-load-btn:hover{background:#166534;}
|
||
.iv-chart-title{font-size:11px;color:var(--muted);flex:1;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
|
||
/* Chart area */
|
||
.iv-chart-area{flex:1;position:relative;display:flex;flex-direction:column;overflow:hidden;min-height:0;}
|
||
.iv-chart-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;text-align:center;padding:40px;}
|
||
.iv-chart-empty .icon{font-size:52px;opacity:.28;}
|
||
.iv-chart-empty p{font-size:12px;line-height:1.7;max-width:280px;}
|
||
#tv-container{flex:1;width:100%;min-height:0;display:none;}
|
||
#ks-container{flex:1;width:100%;min-height:0;overflow:auto;display:none;align-items:center;justify-content:center;background:var(--panel);}
|
||
#ks-container img{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;}
|
||
#ks-container.visible,#tv-container.visible{display:flex;}
|
||
|
||
/* ── Resize ── */
|
||
.iv-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
||
.iv-resize:hover,.iv-resize.dragging{background:#22c55e;}
|
||
|
||
/* ── Chat ── */
|
||
.iv-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.iv-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
||
.iv-chat-label{font-size:10px;font-weight:700;color:#4ade80;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
||
.iv-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;}
|
||
.iv-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
||
.iv-chat-clear:hover{color:#ef4444;}
|
||
.iv-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.iv-msgs::-webkit-scrollbar{width:3px;}
|
||
.iv-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
.iv-msg{display:flex;flex-direction:column;}
|
||
.iv-msg.user{align-items:flex-end;}
|
||
.iv-msg.assistant{align-items:flex-start;}
|
||
.iv-msg.system{align-items:center;}
|
||
.iv-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
|
||
.iv-msg.user .iv-bubble{background:#052e16;color:#dcfce7;border-radius:10px 10px 3px 10px;}
|
||
.iv-msg.assistant .iv-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
||
.iv-msg.system .iv-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
||
.iv-bubble p{margin:3px 0;}
|
||
.iv-bubble h3{font-size:13px;margin:6px 0 3px;}
|
||
.iv-bubble ul,.iv-bubble ol{padding-left:16px;margin:3px 0;}
|
||
.iv-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
||
.iv-bubble strong{font-weight:700;}
|
||
.iv-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
|
||
.iv-bubble th,.iv-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
|
||
.iv-bubble th{background:var(--panel);font-weight:700;}
|
||
/* chart link in chat */
|
||
.iv-chart-link{display:inline-block;margin:4px 0;padding:4px 10px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.25);border-radius:6px;font-size:11px;color:#4ade80;cursor:pointer;}
|
||
.iv-chart-link:hover{background:rgba(34,197,94,.18);}
|
||
.iv-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;}
|
||
.iv-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
||
.iv-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
||
.iv-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:ivDot 1.2s ease-in-out infinite;}
|
||
.iv-dot:nth-child(2){animation-delay:.2s;}.iv-dot:nth-child(3){animation-delay:.4s;}
|
||
@keyframes ivDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
||
.iv-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
||
.iv-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;}
|
||
.iv-textarea:focus{border-color:#22c55e;}
|
||
.iv-send{background:#14532d;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
||
.iv-send:hover:not(:disabled){background:#166534;}
|
||
.iv-send:disabled{opacity:.5;cursor:not-allowed;}
|
||
|
||
/* ── Mobile ── */
|
||
@media(max-width:1024px){
|
||
.iv-resize{display:none;}
|
||
.iv-sb{
|
||
position:fixed;top:0;left:0;bottom:52px;z-index:500;
|
||
width:80vw;max-width:300px;
|
||
transform:translateX(-100%);transition:transform .25s ease;
|
||
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.iv-sb.mob-open{transform:translateX(0);}
|
||
.iv-chat{
|
||
position:fixed;top:0;right:0;bottom:52px;z-index:500;
|
||
width:92vw;max-width:380px;
|
||
transform:translateX(100%);transition:transform .25s ease;
|
||
box-shadow:-4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.iv-chat.mob-open{transform:translateX(0);}
|
||
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499;}
|
||
#mob-backdrop.show{display:block;}
|
||
.iv-center{width:100%;}
|
||
#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;}
|
||
.iv-textarea,input,textarea{font-size:16px !important;}
|
||
}
|
||
@media(min-width:1025px){
|
||
#mob-tabbar{display:none !important;}
|
||
#mob-backdrop{display:none !important;}
|
||
}
|
||
</style>
|
||
<script src="voice-utils.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="iv-hdr">
|
||
<a href="/" class="hdr-btn">← 홈</a>
|
||
<h1>📈 투자 어시스턴트</h1>
|
||
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
|
||
<div class="iv-hdr-sp"></div>
|
||
<button class="hdr-btn" onclick="toggleTheme()">🌙 테마</button>
|
||
</header>
|
||
|
||
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
|
||
|
||
<div id="mob-tabbar" style="display:none">
|
||
<button class="mob-tab" id="mobtab-stocks" onclick="mobToggle('stocks')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg>
|
||
<span>종목</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><rect x="2" y="3" width="20" height="14" rx="2"/><polyline points="8 21 12 17 16 21"/></svg>
|
||
<span>차트</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<span>AI</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="iv-layout">
|
||
|
||
<!-- ── Sidebar ── -->
|
||
<aside class="iv-sb">
|
||
<div class="iv-sb-sec"><span>🇰🇷 코스피/코스닥</span><button class="iv-add-btn" onclick="showAddForm('kr')" title="종목 추가">+</button></div>
|
||
<div id="kr-group"></div>
|
||
<div class="iv-divider"></div>
|
||
<div class="iv-sb-sec"><span>🌍 글로벌</span><button class="iv-add-btn" onclick="showAddForm('tv')" title="종목 추가">+</button></div>
|
||
<div id="tv-group"></div>
|
||
<div class="iv-divider"></div>
|
||
<div class="iv-sb-sec"><span>빠른 질문</span></div>
|
||
<div class="iv-quick-section">
|
||
<button class="iv-qbtn" onclick="sendQuick('현재 글로벌 경제 상황과 주요 투자 리스크를 분석해주세요.')">🌐 거시경제 분석</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('삼성전자(005930) 기업 분석과 밸류에이션을 해주세요.')">🔬 기업 분석</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('안정적인 장기 투자 포트폴리오 구성 방법을 알려주세요.')">💼 포트폴리오 구성</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('ETF 투자 시 핵심 체크포인트와 추천 유형을 알려주세요.')">📊 ETF 투자</button>
|
||
<button class="iv-qbtn" onclick="sendQuick('DCF 밸류에이션 계산 방법을 예시와 함께 알려주세요.')">🧮 DCF 계산</button>
|
||
</div>
|
||
<div class="iv-disclaimer">⚠️ 정보 제공 목적이며 <strong>투자 권유가 아닙니다.</strong> 투자 결정은 본인 책임이며 손실 위험이 있습니다.</div>
|
||
</aside>
|
||
|
||
<!-- ── Center chart ── -->
|
||
<section class="iv-center">
|
||
<!-- Chart bar -->
|
||
<div class="iv-chart-bar">
|
||
<div class="iv-chart-type">
|
||
<button class="iv-ct-btn active" id="ct-kr" onclick="setChartType('kr')">🇰🇷 한국</button>
|
||
<button class="iv-ct-btn" id="ct-tv" onclick="setChartType('tv')">🌍 글로벌</button>
|
||
</div>
|
||
<input class="iv-sym-input" id="sym-input" type="text" placeholder="005930 또는 NASDAQ:AAPL" onkeydown="if(event.key==='Enter')loadChartFromInput()">
|
||
<select class="iv-period-sel" id="period-sel">
|
||
<option value="day">일봉</option>
|
||
<option value="week">주봉</option>
|
||
<option value="month">월봉</option>
|
||
<option value="3month">3개월</option>
|
||
<option value="year">1년</option>
|
||
<option value="3year">3년</option>
|
||
</select>
|
||
<button class="iv-load-btn" onclick="loadChartFromInput()">차트 보기</button>
|
||
<span class="iv-chart-title" id="chart-title"></span>
|
||
</div>
|
||
|
||
<!-- Chart display -->
|
||
<div class="iv-chart-area">
|
||
<div class="iv-chart-empty" id="chart-empty">
|
||
<div class="icon">📈</div>
|
||
<p>왼쪽 종목을 클릭하거나<br>종목코드를 입력해 차트를 불러오세요.<br><br>AI에게 종목 분석을 요청하면<br>차트가 자동으로 열립니다.</p>
|
||
</div>
|
||
<div id="tv-container"></div>
|
||
<div id="ks-container"><img id="ks-img" src="" alt=""></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Resize ── -->
|
||
<div class="iv-resize" id="resize-handle"></div>
|
||
|
||
<!-- ── Chat ── -->
|
||
<div class="iv-chat" id="chat-panel">
|
||
<div class="iv-chat-hdr">
|
||
<span class="iv-chat-label">📈 투자 AI</span>
|
||
<select class="iv-model-sel" id="model-sel" onchange="saveModel()">
|
||
<option value="">기본 모델</option>
|
||
</select>
|
||
<button class="iv-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||
</div>
|
||
<div class="iv-msgs" id="chat-msgs"></div>
|
||
<div class="iv-input-bar">
|
||
<textarea class="iv-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="iv-send" id="send-btn" onclick="sendMessage()">전송</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ── Watchlist ─────────────────────────────────────────────────────────────────
|
||
const WL_KR_KEY='iv_wl_kr';
|
||
const WL_TV_KEY='iv_wl_tv';
|
||
const DEFAULT_KR=[
|
||
{code:'005930',name:'삼성전자',period:'day'},
|
||
{code:'000660',name:'SK하이닉스',period:'day'},
|
||
{code:'005380',name:'현대자동차',period:'day'},
|
||
{code:'035420',name:'NAVER',period:'day'},
|
||
{code:'373220',name:'LG에너지솔루션',period:'day'},
|
||
{code:'035720',name:'카카오',period:'day'},
|
||
];
|
||
const DEFAULT_TV=[
|
||
{sym:'NASDAQ:AAPL',name:'Apple',iv:'D'},
|
||
{sym:'NASDAQ:NVDA',name:'NVIDIA',iv:'D'},
|
||
{sym:'NASDAQ:TSLA',name:'Tesla',iv:'D'},
|
||
{sym:'FOREXCOM:SPXUSD',name:'S&P 500',iv:'D'},
|
||
{sym:'FX:USDKRW',name:'달러 환율',iv:'D'},
|
||
{sym:'TVC:GOLD',name:'금 선물',iv:'D'},
|
||
{sym:'BINANCE:BTCUSDT',name:'비트코인',iv:'D'},
|
||
];
|
||
|
||
let _wlKr=null,_wlTv=null;
|
||
function getKrList(){return _wlKr??DEFAULT_KR;}
|
||
function getTvList(){return _wlTv??DEFAULT_TV;}
|
||
async function loadWatchlist(){
|
||
try{
|
||
const r=await fetch('/api/investor/watchlist',{headers:authH()});
|
||
if(r.ok){const d=await r.json();if(Array.isArray(d.kr))_wlKr=d.kr;if(Array.isArray(d.tv))_wlTv=d.tv;}
|
||
}catch{}
|
||
}
|
||
async function saveWL(){
|
||
try{await fetch('/api/investor/watchlist',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({kr:getKrList(),tv:getTvList()})});}catch{}
|
||
}
|
||
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
|
||
function renderWatchlist(){
|
||
// KR
|
||
const kr=getKrList();
|
||
document.getElementById('kr-group').innerHTML=`<div class="iv-group">${
|
||
kr.map((t,i)=>`
|
||
<div class="iv-ticker" id="kr-${i}" onclick="loadKStock('${escAttr(t.code)}','${escAttr(t.name)}','${escAttr(t.period||'day')}')">
|
||
<div class="iv-t-info">
|
||
<span class="iv-t-code">${escHtml(t.code)}</span>
|
||
<span class="iv-t-name">${escHtml(t.name)}</span>
|
||
</div>
|
||
<div class="iv-t-actions">
|
||
<button class="iv-t-act" onclick="event.stopPropagation();editKr(${i})" title="편집">✏</button>
|
||
<button class="iv-t-act del" onclick="event.stopPropagation();deleteKr(${i})" title="삭제">✕</button>
|
||
</div>
|
||
</div>`).join('')
|
||
}</div>`;
|
||
// TV
|
||
const tv=getTvList();
|
||
document.getElementById('tv-group').innerHTML=`<div class="iv-group">${
|
||
tv.map((t,i)=>`
|
||
<div class="iv-ticker" id="tv-${i}" onclick="loadTV('${escAttr(t.sym)}','${escAttr(t.iv||'D')}','${escAttr(t.name)}')">
|
||
<div class="iv-t-info">
|
||
<span class="iv-t-code">${escHtml(t.sym.split(':').pop())}</span>
|
||
<span class="iv-t-name">${escHtml(t.name)}</span>
|
||
</div>
|
||
<div class="iv-t-actions">
|
||
<button class="iv-t-act" onclick="event.stopPropagation();editTv(${i})" title="편집">✏</button>
|
||
<button class="iv-t-act del" onclick="event.stopPropagation();deleteTv(${i})" title="삭제">✕</button>
|
||
</div>
|
||
</div>`).join('')
|
||
}</div>`;
|
||
}
|
||
|
||
// ── Add / Edit forms ──────────────────────────────────────────────────────────
|
||
function showAddForm(type){
|
||
closeAllForms();
|
||
const container=document.getElementById(type==='kr'?'kr-group':'tv-group');
|
||
const formId='add-form-'+type;
|
||
const formHtml=type==='kr'
|
||
?`<div class="iv-ticker-form" id="${formId}">
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-code" placeholder="종목코드 (예: 005930)" maxlength="10"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-name" placeholder="종목명 (예: 삼성전자)"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="tf-period">
|
||
<option value="day">일봉</option><option value="week">주봉</option>
|
||
<option value="month">월봉</option><option value="3month">3개월</option>
|
||
<option value="year">1년</option><option value="3year">3년</option>
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveAddKr()">추가</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
:`<div class="iv-ticker-form" id="${formId}">
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-sym" placeholder="심볼 (예: NASDAQ:AAPL)" ></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="tf-name" placeholder="이름 (예: Apple)"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="tf-iv">
|
||
<option value="D">일봉</option><option value="W">주봉</option>
|
||
<option value="M">월봉</option><option value="60">60분</option>
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveAddTv()">추가</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
// append form below the group div
|
||
const existing=document.getElementById(formId);
|
||
if(existing){existing.remove();return;}
|
||
const wrap=document.createElement('div');wrap.innerHTML=formHtml;
|
||
container.appendChild(wrap.firstElementChild);
|
||
container.querySelector('#tf-code,#tf-sym')?.focus();
|
||
}
|
||
|
||
function editKr(i){
|
||
closeAllForms();
|
||
const arr=getKrList();const t=arr[i];
|
||
const row=document.getElementById('kr-'+i);if(!row)return;
|
||
const formId='edit-kr-'+i;
|
||
const form=document.createElement('div');form.className='iv-ticker-form';form.id=formId;
|
||
form.innerHTML=`
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-code-${i}" value="${escAttr(t.code)}" placeholder="종목코드"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-name-${i}" value="${escAttr(t.name)}" placeholder="종목명"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="ef-period-${i}">
|
||
${['day','week','month','3month','year','3year'].map(p=>`<option value="${p}"${t.period===p?' selected':''}>${{day:'일봉',week:'주봉',month:'월봉','3month':'3개월',year:'1년','3year':'3년'}[p]||p}</option>`).join('')}
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveEditKr(${i})">저장</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>`;
|
||
row.after(form);
|
||
document.getElementById('ef-code-'+i)?.focus();
|
||
}
|
||
|
||
function editTv(i){
|
||
closeAllForms();
|
||
const arr=getTvList();const t=arr[i];
|
||
const row=document.getElementById('tv-'+i);if(!row)return;
|
||
const formId='edit-tv-'+i;
|
||
const form=document.createElement('div');form.className='iv-ticker-form';form.id=formId;
|
||
form.innerHTML=`
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-sym-${i}" value="${escAttr(t.sym)}" placeholder="심볼"></div>
|
||
<div class="iv-tf-row"><input class="iv-tf-input" id="ef-name-${i}" value="${escAttr(t.name)}" placeholder="이름"></div>
|
||
<div class="iv-tf-row">
|
||
<select class="iv-tf-sel" id="ef-iv-${i}">
|
||
${['D','W','M','60'].map(v=>`<option value="${v}"${t.iv===v?' selected':''}>${{D:'일봉',W:'주봉',M:'월봉','60':'60분'}[v]||v}</option>`).join('')}
|
||
</select>
|
||
<div class="iv-tf-btns">
|
||
<button class="iv-tf-save" onclick="saveEditTv(${i})">저장</button>
|
||
<button class="iv-tf-cancel" onclick="closeAllForms()">취소</button>
|
||
</div>
|
||
</div>`;
|
||
row.after(form);
|
||
document.getElementById('ef-sym-'+i)?.focus();
|
||
}
|
||
|
||
function closeAllForms(){
|
||
document.querySelectorAll('.iv-ticker-form').forEach(f=>f.remove());
|
||
}
|
||
|
||
async function saveAddKr(){
|
||
const code=(document.getElementById('tf-code')?.value||'').trim().replace(/\D/g,'');
|
||
const name=(document.getElementById('tf-name')?.value||'').trim()||code;
|
||
const period=document.getElementById('tf-period')?.value||'day';
|
||
if(!code)return;
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr.push({code,name,period});await saveWL();renderWatchlist();
|
||
}
|
||
async function saveAddTv(){
|
||
const sym=(document.getElementById('tf-sym')?.value||'').trim().toUpperCase();
|
||
const name=(document.getElementById('tf-name')?.value||'').trim()||sym;
|
||
const iv=document.getElementById('tf-iv')?.value||'D';
|
||
if(!sym)return;
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv.push({sym,name,iv});await saveWL();renderWatchlist();
|
||
}
|
||
async function saveEditKr(i){
|
||
const code=(document.getElementById('ef-code-'+i)?.value||'').trim().replace(/\D/g,'');
|
||
const name=(document.getElementById('ef-name-'+i)?.value||'').trim()||code;
|
||
const period=document.getElementById('ef-period-'+i)?.value||'day';
|
||
if(!code)return;
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr[i]={code,name,period};await saveWL();renderWatchlist();
|
||
}
|
||
async function saveEditTv(i){
|
||
const sym=(document.getElementById('ef-sym-'+i)?.value||'').trim().toUpperCase();
|
||
const name=(document.getElementById('ef-name-'+i)?.value||'').trim()||sym;
|
||
const iv=document.getElementById('ef-iv-'+i)?.value||'D';
|
||
if(!sym)return;
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv[i]={sym,name,iv};await saveWL();renderWatchlist();
|
||
}
|
||
async function deleteKr(i){
|
||
if(!_wlKr)_wlKr=[...DEFAULT_KR];
|
||
_wlKr.splice(i,1);await saveWL();renderWatchlist();
|
||
}
|
||
async function deleteTv(i){
|
||
if(!_wlTv)_wlTv=[...DEFAULT_TV];
|
||
_wlTv.splice(i,1);await saveWL();renderWatchlist();
|
||
}
|
||
|
||
// ── 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{}}
|
||
|
||
// ── Mobile overlay ────────────────────────────────────────────────────────────
|
||
function mobCloseAll(){
|
||
document.querySelector('.iv-sb').classList.remove('mob-open');
|
||
document.getElementById('chat-panel').classList.remove('mob-open');
|
||
document.getElementById('mob-backdrop').classList.remove('show');
|
||
['stocks','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
|
||
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)';
|
||
}
|
||
function mobToggle(which){
|
||
const sb=document.querySelector('.iv-sb');
|
||
const cp=document.getElementById('chat-panel');
|
||
const bd=document.getElementById('mob-backdrop');
|
||
if(which==='home'){mobCloseAll();return;}
|
||
const sbOpen=sb.classList.contains('mob-open');
|
||
const cpOpen=cp.classList.contains('mob-open');
|
||
mobCloseAll();
|
||
if(which==='stocks'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-stocks').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
}
|
||
|
||
// ── 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('iv_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('iv_chat_w',panel.offsetWidth);}catch{};});
|
||
})();
|
||
|
||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||
let selectedModel=localStorage.getItem('iv_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('iv_model',selectedModel);}catch{}}
|
||
|
||
// ── Skill ─────────────────────────────────────────────────────────────────────
|
||
const APP_KEY='iv';
|
||
let skillContext='';
|
||
let _sessionId=null;
|
||
function getSessionId(){return _sessionId||'iv_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='iv_'+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',renderMd(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: Investor\ndescription: \uc8fc\uc2dd\u00b7\ucc44\uad8c\u00b7\ubd80\ub3d9\uc0b0\u00b7\ub300\uc548\ud22c\uc790\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\u00b7\uacbd\uc81c\uc9c0\ud45c \ubd84\uc11d\uc744 \uc544\uc6b0\ub974\ub294 \ud22c\uc790 \uc804\ubb38\uac00 \uc5b4\uc2dc\uc2a4\ud134\ud2b8. \ud55c\uad6d\u00b7\ubbf8\uad6d \uc2dc\uc7a5 \ubaa8\ub450 \uc9c0\uc6d0.\nemoji: \"\ud83d\udcc8\"\nversion: 1.0.0\ntriggers: [\uc8fc\uc2dd, \ud22c\uc790, \ud380\ub4dc, \ucc44\uad8c, \ud3ec\ud2b8\ud3f4\ub9ac\uc624, \uc218\uc775\ub960, \ubc30\ub2f9, ETF, \ucf54\uc2a4\ud53c, \ucf54\uc2a4\ub2e5, \ub098\uc2a4\ub2e5, \ud658\uc728, \uae08\ub9ac, \ubd80\ub3d9\uc0b0, \ub9ac\uce20, \uc6d0\uc790\uc7ac, \uc554\ud638\ud654\ud3d0, \ub2ec\ub7ec, \uacbd\uc81c\uc9c0\ud45c, \uc99d\uc2dc, PER, PBR]\n---\n\n\ub2f9\uc2e0\uc740 CFA(\uacf5\uc778\uc7ac\ubb34\ubd84\uc11d\uc0ac) \uc218\uc900\uc758 \ud22c\uc790 \ubd84\uc11d\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\u00b7\ub9ac\uc2a4\ud06c \ud3c9\uac00 \uc804\ubb38 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \ub370\uc774\ud130 \uae30\ubc18\uc73c\ub85c \uc815\ud655\ud558\uace0 \uade0\ud615 \uc7a1\ud78c \ubd84\uc11d\uc744 \uc81c\uacf5\ud558\uc138\uc694. \uc815\ubcf4 \uc81c\uacf5 \ubaa9\uc801\uc774\uba70 \ud22c\uc790 \uad8c\uc720\uac00 \uc544\ub2d9\ub2c8\ub2e4.\n\n---\n\n## \u26a0\ufe0f \uc8fc\uac00 \ucc28\ud2b8 \ub80c\ub354\ub9c1 \uaddc\uce59 \u2014 \ucd5c\uc6b0\uc120 \uc801\uc6a9\n\n### \ud55c\uad6d \uc8fc\uc2dd \u2192 `kstock` \ucf54\ub4dc\ube14\ub85d (\ub124\uc774\ubc84\uae08\uc735)\n\n\ud615\uc2dd: \uc885\ubaa9\ucf54\ub4dc|\uae30\uac04 (\uae30\uac04 \uc0dd\ub7b5 \uc2dc day)\n\n\uc0bc\uc131\uc804\uc790 \uc608\uc2dc:\n```kstock\n005930|day\n```\n\n\uae30\uac04 \uc635\uc158: day(\uc77c\ubd09), week(\uc8fc\ubd09), month(\uc6d4\ubd09), 3month, year, 3year\n\n\uc8fc\uc694 \uc885\ubaa9\ucf54\ub4dc: 005930(\uc0bc\uc131\uc804\uc790), 000660(SK\ud558\uc774\ub2c9\uc2a4), 035720(\uce74\uce74\uc624), 005380(\ud604\ub300\ucc28), 035420(NAVER)\n\n### \ud574\uc678 \uc8fc\uc2dd\u00b7\uc9c0\uc218\u00b7\ud658\uc728\u00b7\uc6d0\uc790\uc7ac \u2192 `tradingview` \ucf54\ub4dc\ube14\ub85d\n\n\ud615\uc2dd: \uac70\ub798\uc18c:\uc885\ubaa9\ucf54\ub4dc|\ubd09\uc8fc\uae30\n\n\uc608\uc2dc:\n```tradingview\nNASDAQ:AAPL|D\n```\n\n\ubd09\uc8fc\uae30: 1/5/15/60=\ubd84\ubd09, D=\uc77c\ubd09, W=\uc8fc\ubd09, M=\uc6d4\ubd09\n\uc8fc\uc694 \uc2ec\ubcfc: NASDAQ:AAPL, NASDAQ:NVDA, NYSE:JPM, FX:USDKRW, TVC:GOLD, BINANCE:BTCUSDT, FOREXCOM:SPXUSD\n\n**\u26a0\ufe0f \uc808\ub300 \uae08\uc9c0**:\n- \ud55c\uad6d \uc8fc\uc2dd\uc5d0 tradingview \ucf54\ub4dc\ube14\ub85d \uc0ac\uc6a9 (KRX: \uc2ec\ubcfc\uc740 \uc9c0\uc6d0 \uc548 \ub428)\n- JSON \ud615\uc2dd(`{...}`) \uc0ac\uc6a9\n- \"\ucc28\ud2b8\ub97c \ubcf4\uc5ec\ub4dc\ub9b4 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4\" / \"TradingView\uc5d0\uc11c\ub9cc \uc81c\uacf5\ub429\ub2c8\ub2e4\" \ub4f1\uc758 \uac70\uc808 \ubb38\uad6c\n\n---\n\n### \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ub3c4\uad6c\n\n| \ub3c4\uad6c | \ud65c\uc6a9 \uc0c1\ud669 |\n|---|---|\n| `web_search` | \uae30\uc5c5 \uacf5\uc2dc\u00b7\uc2e4\uc801\u00b7\uacbd\uc81c\uc9c0\ud45c\u00b7\ub274\uc2a4\u00b7\uc2dc\uc7a5 \ub3d9\ud5a5 \uac80\uc0c9 |\n| `pdf_read` | \uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\uac10\uc0ac\ubcf4\uace0\uc11c\u00b7\ud22c\uc790\uc124\uba85\uc11c\u00b7\ub9ac\uc11c\uce58 \ub9ac\ud3ec\ud2b8 \ubd84\uc11d |\n| `excel_read` | Excel \uc7ac\ubb34\ubaa8\ub378\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \ub370\uc774\ud130\u00b7\uc2dc\uacc4\uc5f4 \uc218\uc775\ub960 \uc77d\uae30 |\n| `excel_write` | \ubc38\ub958\uc5d0\uc774\uc158 \ubaa8\ub378\u00b7\ud3ec\ud2b8\ud3f4\ub9ac\uc624 \ubd84\uc11d \uacb0\uacfc\ub97c Excel\ub85c \uc800\uc7a5 |\n| `python_eval` | \ubc38\ub958\uc5d0\uc774\uc158 \uacc4\uc0b0, \ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uc218\uc775\ub960, \ubcf5\ub9ac\u00b7\ud604\uc7ac\uac00\uce58, \uc7ac\ubb34\ube44\uc728 \uc0b0\ucd9c |\n\n### \uc6b0\uc120 \ucc38\uc870 \uc0ac\uc774\ud2b8\n\n`web_search` \ud638\ucd9c \uc2dc \uc544\ub798 **site: \uc5f0\uc0b0\uc790\ub97c query\uc5d0 \ud3ec\ud568**\ud574 \uc2e0\ub8b0\ud560 \uc218 \uc788\ub294 \ucd9c\ucc98\uc5d0\uc11c \uc6b0\uc120 \uac80\uc0c9\ud558\uc138\uc694.\n\n| \uc8fc\uc81c | site: \uc5f0\uc0b0\uc790 | \uc124\uba85 |\n|---|---|---|\n| \ud55c\uad6d \uae30\uc5c5 \uacf5\uc2dc | `site:dart.fss.or.kr` | \uae08\uc735\uac10\ub3c5\uc6d0 DART (\uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\uc2e4\uc801\u00b7\uacf5\uc2dc) |\n| \ud55c\uad6d\uac70\ub798\uc18c \ud1b5\uacc4 | `site:krx.co.kr` | KRX \uc2dc\uc7a5 \ud1b5\uacc4\u00b7\uc885\ubaa9 \uc815\ubcf4 |\n| \uae08\uc735\uac10\ub3c5\uc6d0 | `site:fss.or.kr` | \uae08\uc735 \uaddc\uc81c\u00b7\ud22c\uc790\uc790 \ubcf4\ud638 \uc815\ubcf4 |\n| \ud55c\uad6d\uc740\ud589 \uacbd\uc81c\ud1b5\uacc4 | `site:bok.or.kr` | \uae30\uc900\uae08\ub9ac\u00b7\ud1b5\ud654\ub7c9\u00b7GDP\u00b7\ubb3c\uac00 |\n| \ubbf8\uad6d \uae30\uc5c5 \uacf5\uc2dc | `site:sec.gov` | SEC EDGAR (10-K\u00b710-Q\u00b78-K) |\n| \ubbf8\uad6d \uacbd\uc81c\uc9c0\ud45c | `site:fred.stlouisfed.org` | \uc5f0\uc900 FRED (\uae08\ub9ac\u00b7CPI\u00b7\uc2e4\uc5c5\ub960\u00b7GDP) |\n| \ud22c\uc790 \uac1c\ub150\u00b7\uc774\ub860 | `site:investopedia.com` | \uae08\uc735 \uc6a9\uc5b4\u00b7\uc9c0\ud45c\u00b7\uc804\ub7b5 \ud574\uc124 |\n| \uae00\ub85c\ubc8c \uac70\uc2dc\uacbd\uc81c | `site:imf.org` | IMF \uc138\uacc4\uacbd\uc81c\uc804\ub9dd\u00b7\uad6d\uac00\ubcc4 \ub370\uc774\ud130 |\n\n\uc608\uc2dc: \uc0bc\uc131\uc804\uc790 \ucd5c\uc2e0 \uc2e4\uc801 \u2192 `web_search(\"site:dart.fss.or.kr \uc0bc\uc131\uc804\uc790 \uc0ac\uc5c5\ubcf4\uace0\uc11c 2024\")`\n\n---\n\n### \uae30\uc5c5 \ubd84\uc11d \ubc29\ubc95\ub860\n\n\uae30\uc5c5 \ubd84\uc11d \uc694\uccad \uc2dc \ub2e4\uc74c \uc21c\uc11c\ub85c \uc811\uadfc\ud558\uc138\uc694:\n\n**1\ub2e8\uacc4 \u2014 \uc0ac\uc5c5 \uc774\ud574**\n- \ud575\uc2ec \uc0ac\uc5c5 \ubaa8\ub378, \uc8fc\uc694 \uc81c\ud488\u00b7\uc11c\ube44\uc2a4, \uc218\uc775 \uad6c\uc870\n- \uc0b0\uc5c5 \ub0b4 \uacbd\uc7c1 \uc704\uce58(Moat), \uc8fc\uc694 \uacbd\uc7c1\uc0ac\n\n**2\ub2e8\uacc4 \u2014 \uc7ac\ubb34 \ubd84\uc11d** (`python_eval` \ud65c\uc6a9)\n- \ub9e4\ucd9c\uc131\uc7a5\ub960, \uc601\uc5c5\uc774\uc775\ub960, \uc21c\uc774\uc775\ub960\n- ROE, ROA, ROIC\n- \ubd80\ucc44\ube44\uc728, \uc774\uc790\ubcf4\uc0c1\ubc30\uc728, FCF(\uc789\uc5ec\ud604\uae08\ud750\ub984)\n\n**3\ub2e8\uacc4 \u2014 \ubc38\ub958\uc5d0\uc774\uc158**\n- **\uc0c1\ub300\uac00\uce58\ubc95**: PER, PBR, EV/EBITDA, PSR \u2192 \uc5c5\uc885 \ud3c9\uade0\u00b7\uacfc\uac70 \ubc34\ub4dc \ube44\uad50\n- **\uc808\ub300\uac00\uce58\ubc95**: DCF (WACC + \uc794\uc5ec\uac00\uce58)\n- **\ubaa9\ud45c\uc8fc\uac00**: \ubc38\ub958\uc5d0\uc774\uc158 \ubc94\uc704 \uc81c\uc2dc (\ub099\uad00\u00b7\uae30\ubcf8\u00b7\ube44\uad00 \uc2dc\ub098\ub9ac\uc624)\n\n```python\n# DCF \uac04\uc774 \uacc4\uc0b0 \uc608\uc2dc\nfcf = [100, 110, 121, 133, 146] # 5\ub144 FCF \ucd94\uc815 (\uc5b5\uc6d0)\nwacc = 0.09\nterminal_growth = 0.03\nterminal_value = fcf[-1] * (1 + terminal_growth) / (wacc - terminal_growth)\npv = sum(f / (1 + wacc)**i for i, f in enumerate(fcf, 1))\npv += terminal_value / (1 + wacc)**5\nprint(f\"\ub0b4\uc7ac\uac00\uce58(PV): {pv:.0f}\uc5b5\uc6d0\")\n```\n\n**4\ub2e8\uacc4 \u2014 \ub9ac\uc2a4\ud06c \uc694\uc778**\n- \uc0b0\uc5c5 \ub9ac\uc2a4\ud06c, \uaddc\uc81c \ub9ac\uc2a4\ud06c, \ud658\uc728\u00b7\uae08\ub9ac \ubbfc\uac10\ub3c4\n- \uc8fc\uc694 \uac00\uc815 \ubcc0\ud654 \uc2dc \ubc38\ub958\uc5d0\uc774\uc158 \ubbfc\uac10\ub3c4(Sensitivity)\n\n---\n\n### \ud3ec\ud2b8\ud3f4\ub9ac\uc624 \uad00\ub9ac\n\n- **\uc790\uc0b0\ubc30\ubd84(Asset Allocation)**: \uc8fc\uc2dd\u00b7\ucc44\uad8c\u00b7\ud604\uae08\u00b7\ub300\uc548\ud22c\uc790 \ube44\uc911 \uc124\uacc4\n- **\ubd84\uc0b0\ud22c\uc790**: \uc139\ud130\u00b7\uad6d\uac00\u00b7\ud1b5\ud654 \ubd84\uc0b0, \uc0c1\uad00\uacc4\uc218(correlation) \uace0\ub824\n- **\ub9ac\ubc38\ub7f0\uc2f1**: \ubaa9\ud45c \ube44\uc911 \uc774\ud0c8 \uc2dc \uc7ac\uc870\uc815 \uae30\uc900 (\uc2dc\uac04 \uae30\ubc18 vs \uc784\uacc4\uac12 \uae30\ubc18)\n- **\ub9ac\uc2a4\ud06c \uc9c0\ud45c**: \ubcc0\ub3d9\uc131(\u03c3), \ubca0\ud0c0(\u03b2), \ucd5c\ub300\ub099\ud3ed(MDD), \uc0e4\ud504\ube44\uc728(Sharpe Ratio)\n\n```python\nimport math\n# \uc0e4\ud504\ube44\uc728 \uacc4\uc0b0\nreturns = [0.12, 0.08, -0.05, 0.15, 0.10]\nrf = 0.035 # \ubb34\uc704\ud5d8\uc218\uc775\ub960\navg_r = sum(returns) / len(returns)\nstd_r = math.sqrt(sum((r - avg_r)**2 for r in returns) / len(returns))\nsharpe = (avg_r - rf) / std_r\nprint(f\"\uc0e4\ud504\ube44\uc728: {sharpe:.2f}\")\n```\n\n---\n\n### \uc2dc\uc7a5 \ubd84\uc11d\n\n#### \uac70\uc2dc\uacbd\uc81c \uc9c0\ud45c \ud574\uc11d\n| \uc9c0\ud45c | \ud22c\uc790 \uc2dc\uc0ac\uc810 |\n|---|---|\n| **\uae30\uc900\uae08\ub9ac** | \uae08\ub9ac \uc0c1\uc2b9 \u2192 \ucc44\uad8c \uac00\uaca9\u2193, \uc131\uc7a5\uc8fc \ubc38\ub958\uc5d0\uc774\uc158 \uc555\ubc15 |\n| **CPI (\ubb3c\uac00)** | \uace0\ubb3c\uac00 \u2192 \uc2e4\uc9c8\uc218\uc775\ub960\u2193, \uc6d0\uc790\uc7ac\u00b7\ubd80\ub3d9\uc0b0 \ud5e4\uc9c0 \uc720\ub9ac |\n| **\uc2e4\uc5c5\ub960\u00b7PMI** | \uacbd\uae30 \uc120\ud589\uc9c0\ud45c, \uae30\uc5c5 \uc2e4\uc801 \ubc29\ud5a5\uc131 |\n| **\ud658\uc728** | \uc218\ucd9c \uae30\uc5c5 \uc218\uc775\uc131, \uc678\uad6d\uc778 \uc790\uae08 \uc720\ucd9c\uc785 |\n| **\uc7a5\ub2e8\uae30 \uae08\ub9ac\ucc28** | \uc5ed\uc804 \uc2dc \uacbd\uae30\uce68\uccb4 \uc120\ud589 \uc2e0\ud638 |\n\n#### \uc139\ud130 \ubd84\uc11d\n- \uacbd\uae30 \uc0ac\uc774\ud074\ubcc4 \uc720\ub9ac \uc139\ud130: \uacbd\uae30\ud68c\ubcf5(\uc18c\uc7ac\u00b7\uc0b0\uc5c5\uc7ac) \u2192 \ud638\ud669(IT\u00b7\uc18c\ube44\uc7ac) \u2192 \ub454\ud654(\ud5ec\uc2a4\ucf00\uc5b4\u00b7\uc720\ud2f8\ub9ac\ud2f0) \u2192 \uce68\uccb4(\ud544\uc218\uc18c\ube44\uc7ac\u00b7\ud604\uae08)\n- \ud55c\uad6d \uc2dc\uc7a5 \ud2b9\uc131: \ubc18\ub3c4\uccb4(\uc0bc\uc131\u00b7SK\ud558\uc774\ub2c9\uc2a4) \ube44\uc911 \uacfc\ub300 \u2192 \uae00\ub85c\ubc8c \ubc18\ub3c4\uccb4 \uc0ac\uc774\ud074 \uc5f0\ub3d9\uc131 \uc8fc\uc758\n\n#### \uae30\uc220\uc801 \ubd84\uc11d (\ubcf4\uc870 \ucc38\uace0\uc6a9)\n- \uc774\ub3d9\ud3c9\uade0\uc120(MA 20\u00b760\u00b7120\u00b7200), \uace8\ub4e0\ud06c\ub85c\uc2a4\u00b7\ub370\ub4dc\ud06c\ub85c\uc2a4\n- RSI (\uacfc\ub9e4\uc218 70\u2191 / \uacfc\ub9e4\ub3c4 30\u2193), MACD, \ubcfc\ub9b0\uc800\ubc34\ub4dc\n- \uc9c0\uc9c0\u00b7\uc800\ud56d \uad6c\uac04, \uac70\ub798\ub7c9 \ud655\uc778\n\n---\n\n### \ud22c\uc790 \uc720\ud615\ubcc4 \uac00\uc774\ub4dc\n\n#### \ud55c\uad6d \uc8fc\uc2dd\n- KOSPI\u00b7KOSDAQ \uc885\ubaa9: DART \uacf5\uc2dc \u2192 \uc0ac\uc5c5\ubcf4\uace0\uc11c\u00b7\ubd84\uae30\ubcf4\uace0\uc11c \ud655\uc778\n- \ubc30\ub2f9\uc8fc: \ubc30\ub2f9\uc218\uc775\ub960\u00b7\ubc30\ub2f9\uc131\ud5a5\u00b7\ubc30\ub2f9\uc131\uc7a5\ub960 \ubd84\uc11d\n- ETF: \ucd94\uc801\uc9c0\uc218\u00b7\ucd1d\ubcf4\uc218(TER)\u00b7\uad34\ub9ac\uc728\u00b7\ucd94\uc801\uc624\ucc28\n\n#### \ubbf8\uad6d \uc8fc\uc2dd\n- SEC EDGAR 10-K(\uc5f0\uac04)\u00b710-Q(\ubd84\uae30) \ubd84\uc11d\n- ADR\u00b7\uc9c1\uc811 \ud22c\uc790 \uc2dc \ud658\uc728\u00b7\uc138\uae08(\ubc30\ub2f9 \uc6d0\ucc9c\uc9d5\uc218 15%) \uace0\ub824\n\n#### \ucc44\uad8c\n- \ub4c0\ub808\uc774\uc158(Duration)\u00b7\ucee8\ubca1\uc2dc\ud2f0(Convexity) \uacc4\uc0b0\n- \uc2e0\uc6a9\ub4f1\uae09(KAA\u00b7\ubb34\ub514\uc2a4\u00b7S&P), \uc2a4\ud504\ub808\ub4dc \ubd84\uc11d\n- \uae08\ub9ac \uc2dc\ub098\ub9ac\uc624\ubcc4 \ucc44\uad8c \uac00\uaca9 \ubcc0\ub3d9 \uc2dc\ubbac\ub808\uc774\uc158\n\n#### \ubd80\ub3d9\uc0b0\n- \uc784\ub300\uc218\uc775\ub960 = \uc5f0 \uc784\ub300\ub8cc / \ub9e4\ub9e4\uac00\n- \uc790\ubcf8\ud658\uc6d0\uc728(Cap Rate) = \uc21c\uc601\uc5c5\uc774\uc775(NOI) / \uc790\uc0b0\uac00\uce58\n- \ub808\ubc84\ub9ac\uc9c0 \ud6a8\uacfc\u00b7LTV \uaddc\uc81c\u00b7\ubcf4\uc720\uc138 \uace0\ub824\n\n#### \ub300\uc548\ud22c\uc790\u00b7ETF\n- \uc6d0\uc790\uc7ac(\uae08\u00b7\uc6d0\uc720\u00b7\uad6c\ub9ac): \uc778\ud50c\ub808\uc774\uc158 \ud5e4\uc9c0, \uacf5\uae09\u00b7\uc218\uc694 \uc0ac\uc774\ud074\n- \ub9ac\uce20(REITs): \ubc30\ub2f9\uc218\uc775\ub960\u00b7FFO(\uc870\uc815\uc6b4\uc601\ud604\uae08\ud750\ub984) \ubd84\uc11d\n- \uac00\uc0c1\uc790\uc0b0: \uace0\ubcc0\ub3d9\uc131\u00b7\uaddc\uc81c \ub9ac\uc2a4\ud06c \uba85\uc2dc, \uc18c\uc561 \ube44\uc911 \uc6d0\uce59\n\n---\n\n### \uc751\ub2f5 \ud1a4 \uac00\uc774\ub4dc\n\n- **\ub370\uc774\ud130 \uc911\uc2ec**: \uc218\uce58\uc640 \ucd9c\ucc98\ub97c \uba3c\uc800, \uc758\uacac\uc740 \ub098\uc911\uc5d0\n- **\uc2dc\ub098\ub9ac\uc624 \uc81c\uc2dc**: \ub099\uad00\u00b7\uae30\ubcf8\u00b7\ube44\uad00 3\uac00\uc9c0 \uc2dc\ub098\ub9ac\uc624\ub85c \ubc94\uc704 \uc81c\uc2dc\n- **\ub9ac\uc2a4\ud06c \uade0\ud615**: \uae30\ud68c \uc694\uc778\uacfc \uc704\ud5d8 \uc694\uc778\uc744 \ud56d\uc0c1 \ud568\uaed8 \uc11c\uc220\n- **\uacc4\uc0b0 \ud22c\uba85\uc131**: `python_eval`\ub85c \uc0b0\uc2dd \uacf5\uac1c, \uac00\uc815(assumption) \uba85\uc2dc\n- **\ud22c\uc790 \uad8c\uc720 \uae08\uc9c0**: \"~\uc5d0 \ud22c\uc790\ud558\uc138\uc694\" \ub300\uc2e0 \"~\ub97c \uace0\ub824\ud574\ubcfc \uc218 \uc788\uc2b5\ub2c8\ub2e4\" \ud615\uc2dd\n";
|
||
function loadSkill(){
|
||
skillContext=SKILL_CONTENT;
|
||
document.getElementById('skill-badge').textContent='📈 투자 전문가 활성';
|
||
}
|
||
|
||
// ── Chart ─────────────────────────────────────────────────────────────────────
|
||
let chartType='kr'; // 'kr' or 'tv'
|
||
let tvReady=false;
|
||
|
||
function setChartType(type){
|
||
chartType=type;
|
||
document.getElementById('ct-kr').classList.toggle('active',type==='kr');
|
||
document.getElementById('ct-tv').classList.toggle('active',type==='tv');
|
||
const ph=type==='kr'?'005930 (종목코드)':'NASDAQ:AAPL';
|
||
document.getElementById('sym-input').placeholder=ph;
|
||
document.getElementById('period-sel').style.display=type==='kr'?'':'none';
|
||
}
|
||
|
||
function showEmpty(){
|
||
document.getElementById('chart-empty').style.display='';
|
||
document.getElementById('tv-container').classList.remove('visible');
|
||
document.getElementById('ks-container').classList.remove('visible');
|
||
document.getElementById('chart-title').textContent='';
|
||
}
|
||
|
||
// Korean stock via Naver Finance image
|
||
function loadKStock(code,name,period){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
period=period||document.getElementById('period-sel').value||'day';
|
||
setChartType('kr');
|
||
document.getElementById('sym-input').value=code;
|
||
document.getElementById('period-sel').value=period;
|
||
// highlight sidebar
|
||
document.querySelectorAll('.iv-ticker').forEach(el=>el.classList.remove('active'));
|
||
const periodMap={day:'day',week:'week',month:'month','3month':'3month',year:'year','3year':'3year'};
|
||
const navPeriod=periodMap[period]||'day';
|
||
const imgUrl=`https://ssl.pstatic.net/imgfinance/chart/item/area/${navPeriod}/${code}.png`;
|
||
const navUrl=`https://finance.naver.com/item/main.naver?code=${code}`;
|
||
document.getElementById('chart-empty').style.display='none';
|
||
document.getElementById('tv-container').classList.remove('visible');
|
||
const ksC=document.getElementById('ks-container');
|
||
ksC.classList.add('visible');
|
||
const img=document.getElementById('ks-img');
|
||
img.src='';
|
||
img.onerror=()=>{ksC.innerHTML=`<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px">차트를 불러올 수 없습니다.<br><a href="${navUrl}" target="_blank" style="color:#4ade80">네이버금융에서 보기 ↗</a></div>`;};
|
||
img.onload=()=>{};
|
||
img.src=imgUrl;img.alt=name||code;
|
||
const periodLabel={day:'일봉',week:'주봉',month:'월봉','3month':'3개월',year:'1년','3year':'3년'}[navPeriod]||navPeriod;
|
||
document.getElementById('chart-title').innerHTML=`<a href="${navUrl}" target="_blank" style="color:#4ade80;text-decoration:none">${name||code} · ${periodLabel} (네이버금융) ↗</a>`;
|
||
}
|
||
|
||
// TradingView widget
|
||
function loadTV(symbol,interval,name){
|
||
if(window.innerWidth<=1024) mobCloseAll();
|
||
interval=interval||'D';
|
||
setChartType('tv');
|
||
document.getElementById('sym-input').value=symbol;
|
||
document.getElementById('chart-empty').style.display='none';
|
||
document.getElementById('ks-container').classList.remove('visible');
|
||
const tvC=document.getElementById('tv-container');
|
||
tvC.classList.add('visible');
|
||
tvC.innerHTML='<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px">차트 로딩 중...</div>';
|
||
document.getElementById('chart-title').textContent=name||symbol;
|
||
const isDark=document.documentElement.getAttribute('data-theme')!=='light';
|
||
const containerId='tv-chart-'+Date.now();
|
||
const inner=document.createElement('div');inner.id=containerId;inner.style.cssText='width:100%;height:100%;';
|
||
tvC.innerHTML='';tvC.appendChild(inner);
|
||
function doInit(){
|
||
try{
|
||
new TradingView.widget({
|
||
container_id:containerId,autosize:true,symbol,interval,
|
||
timezone:'Asia/Seoul',theme:isDark?'dark':'light',
|
||
style:'1',locale:'kr',enable_publishing:false,
|
||
allow_symbol_change:true,save_image:false,
|
||
withdateranges:true,hide_side_toolbar:false,
|
||
studies:['RSI@tv-basicstudies','MACD@tv-basicstudies'],
|
||
});
|
||
}catch(e){tvC.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center">TradingView 차트 로드 실패: ${e.message}</div>`;}
|
||
}
|
||
if(window.TradingView&&window.TradingView.widget){doInit();}
|
||
else{
|
||
const s=document.createElement('script');s.src='https://s3.tradingview.com/tv.js';
|
||
s.onload=doInit;s.onerror=()=>{tvC.innerHTML='<div style="color:var(--muted);font-size:12px;padding:20px;text-align:center">TradingView 스크립트를 로드할 수 없습니다.</div>';};
|
||
document.head.appendChild(s);
|
||
}
|
||
}
|
||
|
||
function loadChartFromInput(){
|
||
const raw=document.getElementById('sym-input').value.trim();
|
||
if(!raw)return;
|
||
const period=document.getElementById('period-sel').value||'day';
|
||
if(chartType==='kr'||/^\d{6}$/.test(raw)){loadKStock(raw.replace(/\D/g,''),raw,period);}
|
||
else{
|
||
// Parse interval from period select: day→D, week→W, month→M
|
||
const ivMap={day:'D',week:'W',month:'M','3month':'3M',year:'12M','3year':'60M'};
|
||
loadTV(raw.toUpperCase(),ivMap[period]||'D',raw);
|
||
}
|
||
}
|
||
|
||
// Intercept kstock/tradingview blocks from AI response and show in center
|
||
function processChartBlocks(text){
|
||
// kstock block: ```kstock\n005930|day\n```
|
||
const ksMatch=text.match(/```kstock\s*\n([^`]+)\n```/);
|
||
if(ksMatch){
|
||
const parts=ksMatch[1].trim().split('|');
|
||
const code=parts[0].trim().replace(/\D/g,'');
|
||
const period=(parts[1]||'day').trim().toLowerCase();
|
||
if(code){
|
||
setTimeout(()=>loadKStock(code,'',period),100);
|
||
return true;
|
||
}
|
||
}
|
||
// tradingview block: ```tradingview\nNASDAQ:AAPL|D\n```
|
||
const tvMatch=text.match(/```tradingview\s*\n([^`]+)\n```/);
|
||
if(tvMatch){
|
||
const parts=tvMatch[1].trim().split('|');
|
||
const symbol=parts[0].trim().toUpperCase();
|
||
const interval=(parts[1]||'D').trim().toUpperCase();
|
||
if(symbol){
|
||
setTimeout(()=>loadTV(symbol,interval,symbol),100);
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='iv-msg '+role;
|
||
const b=document.createElement('div');b.className='iv-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 renderMd(text){
|
||
// First extract chart blocks to replace with links
|
||
let out=text;
|
||
// Replace kstock blocks with a clickable chip
|
||
out=out.replace(/```kstock\s*\n([^`]+)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const code=parts[0].trim().replace(/\D/g,'');const period=(parts[1]||'day').trim();
|
||
if(!code)return m;
|
||
return `<span class="iv-chart-link" onclick="loadKStock('${code}','${code}','${period}')">📈 ${code} ${period} 차트 보기</span>`;
|
||
});
|
||
// Replace tradingview blocks with a clickable chip
|
||
out=out.replace(/```tradingview\s*\n([^`]+)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const sym=parts[0].trim().toUpperCase();const iv=(parts[1]||'D').trim();
|
||
if(!sym)return m;
|
||
return `<span class="iv-chart-link" onclick="loadTV('${sym}','${iv}','${sym}')">📈 ${sym} 차트 보기</span>`;
|
||
});
|
||
return out
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
// restore already-HTML chart links before further escaping
|
||
.replace(/<span class="iv-chart-link"([^&]*)>(.*?)<\/span>/g,(m)=>{
|
||
// this won't work — need to do escaping AFTER substitution
|
||
return m;
|
||
})
|
||
// Actually process differently below
|
||
;
|
||
}
|
||
|
||
function renderMdSafe(text){
|
||
// Do chart block substitution BEFORE HTML escaping
|
||
let charts=[];
|
||
let out=text
|
||
.replace(/```kstock\s*\n([^`]+?)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const code=parts[0].trim().replace(/\D/g,'');const period=(parts[1]||'day').trim().toLowerCase();
|
||
if(!code)return m;
|
||
const ph=`\x00CHART${charts.length}\x00`;
|
||
charts.push(`<span class="iv-chart-link" onclick="loadKStock('${escAttr(code)}','${escAttr(code)}','${escAttr(period)}')">📈 ${escHtml(code)} · ${escHtml(period)} 차트 보기</span>`);
|
||
return ph;
|
||
})
|
||
.replace(/```tradingview\s*\n([^`]+?)\n```/g,(m,body)=>{
|
||
const parts=body.trim().split('|');const sym=parts[0].trim().toUpperCase();const iv=(parts[1]||'D').trim().toUpperCase();
|
||
if(!sym)return m;
|
||
const ph=`\x00CHART${charts.length}\x00`;
|
||
charts.push(`<span class="iv-chart-link" onclick="loadTV('${escAttr(sym)}','${escAttr(iv)}','${escAttr(sym)}')">📈 ${escHtml(sym)} · ${escHtml(iv)} 차트 보기</span>`);
|
||
return ph;
|
||
});
|
||
// HTML escape
|
||
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');
|
||
// Restore chart placeholders
|
||
charts.forEach((html,i)=>{out=out.replace(new RegExp(`\x00CHART${i}\x00`,'g'),html);});
|
||
return out;
|
||
}
|
||
|
||
async function sendMessage(){
|
||
const input=document.getElementById('chat-input');
|
||
const msg=input.value.trim();
|
||
if(!msg||document.getElementById('send-btn').disabled)return;
|
||
input.value='';autoResize(input);
|
||
if(window.innerWidth<=1024) mobToggle('chat');
|
||
addMsg('user',escHtml(msg));
|
||
await callChat(msg);
|
||
}
|
||
|
||
function sendQuick(msg){
|
||
if(window.innerWidth<=1024){mobCloseAll();}
|
||
addMsg('user',escHtml(msg));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='iv-msg assistant';
|
||
typingDiv.innerHTML='<div class="iv-typing"><div class="iv-dot"></div><div class="iv-dot"></div><div class="iv-dot"></div></div>';
|
||
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
||
let bubble=null,textBuf='',thinkBuf='',chartLoaded=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='iv-msg assistant';
|
||
bubble=document.createElement('div');bubble.className='iv-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="iv-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMdSafe(textBuf);
|
||
msgs.scrollTop=msgs.scrollHeight;
|
||
}
|
||
// Auto-load first chart block found in stream (once)
|
||
if(!chartLoaded&&textBuf.includes('```')){
|
||
if(processChartBlocks(textBuf)){chartLoaded=true;}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if(!chartLoaded&&textBuf)processChartBlocks(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='iv_'+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';}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
(async()=>{
|
||
const ok=await checkAuth();if(!ok)return;
|
||
await initSessionId();
|
||
await loadHistory();
|
||
await loadWatchlist();
|
||
renderWatchlist();
|
||
loadTV('AMEX:XLK','D','XLK (기술주 섹터 ETF)');
|
||
await Promise.all([loadSkill(),loadModels()]);
|
||
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
|
||
initAppVoice(sendMessage);
|
||
document.getElementById('chat-input').focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|