Files
homeclaw/web-ui/investor-app.html
T
kimandClaude Sonnet 4.6 75a36cdc7d v3.2.2: 폴더블 폰 모바일 패널 토글 전면 수정 + 탐정앱 네트워크 분석 + 음성 유틸
- mobToggle 가드 640→1024: 음악·슬라이드·코드·날씨·언어·치과·ESP32·아두이노 앱
  (CSS max-width:1024px인데 JS guard가 >640으로 남아 폴드 내부화면에서 토글 불가)
- #mob-tabbar z-index 400/490→600, 패널 bottom 0→52px, backdrop inset 0 0 52px 0
  (backdrop이 탭바를 가려 열린 상태에서 다른 탭 전환 불가 문제 해결)
- 모든 innerWidth<=640 자동트리거 →<=1024 통일 (9개 앱)
- 탐정앱: 네트워크 분석 패널 (IP조회/Traceroute/Nmap/DNS/WHOIS/SSL/취약점/해시/웹스캔/배너/CVE)
- voice-utils.js 분리 — 서브앱 공통 음성입력+TTS 유틸
- app.js: 컨텍스트 토큰 게이지 (세션 목록에 tok/max 표시), usage 이벤트 처리
- routes-music.ts: Demucs 보컬분리 + YouTube 다운로드 잡 관리 (TTL 자동정리)
- server-v2.ts: 탐정 네트워크 API (traceroute/dns/whois/ssl/nmap/vuln/hash/webscan/cve)
- session.ts: resolveNumCtx export, /api/agent/session contextTokenEstimate 노출
- config.json: 카카오 채널 설정, ITS 교통 API 키 vault 참조
- styles.css: app-mic-btn / app-tts-btn 공통 스타일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 19:16:59 +09:00

852 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📈 투자 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
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,'&#39;').replace(/"/g,'&quot;');}
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
// restore already-HTML chart links before further escaping
.replace(/&lt;span class="iv-chart-link"([^&]*)&gt;(.*?)&lt;\/span&gt;/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('${code}','${code}','${period}')">📈 ${code} · ${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('${sym}','${iv}','${sym}')">📈 ${sym} · ${iv} 차트 보기</span>`);
return ph;
});
// HTML escape
out=out
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<h3>)/g,'$1').replace(/(<\/h3>)<\/p>/g,'$1')
.replace(/<p>(<pre>)/g,'$1').replace(/(<\/pre>)<\/p>/g,'$1')
.replace(/<p>(<ul>)/g,'$1').replace(/(<\/ul>)<\/p>/g,'$1');
// 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();
setChartType('kr');
await Promise.all([loadSkill(),loadModels()]);
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>