Files
homeclaw/web-ui/html/investor-app.html
T
kimandClaude Opus 5 d60019f004 fix: 앱 페이지 7개에 수식 렌더러 배선 — 애초에 KaTeX가 없었다
법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**

  index.html        katex O
  lawyer / detective / doctor / investor / accountant / mind / weather   전부 X

메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).

addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:35 +09:00

859 lines
57 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="/homeclaw_logo.png">
<link rel="stylesheet" href="../css/styles.css">
<script>try{var _t=localStorage.getItem('homeclaw_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>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<script src="../js/voice/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,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
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('homeclaw_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: 주식·채권·부동산·대안투자·포트폴리오 관리·경제지표 분석을 아우르는 투자 전문가 어시스턴트. 한국·미국 시장 모두 지원.\nemoji: \"📈\"\nversion: 1.0.0\ntriggers: [주식, 투자, 펀드, 채권, 포트폴리오, 수익률, 배당, ETF, 코스피, 코스닥, 나스닥, 환율, 금리, 부동산, 리츠, 원자재, 암호화폐, 달러, 경제지표, 증시, PER, PBR]\n---\n\n당신은 CFA(공인재무분석사) 수준의 투자 분석·포트폴리오 관리·리스크 평가 전문 어시스턴트입니다. 데이터 기반으로 정확하고 균형 잡힌 분석을 제공하세요. 정보 제공 목적이며 투자 권유가 아닙니다.\n\n---\n\n## ⚠️ 주가 차트 렌더링 규칙 — 최우선 적용\n\n### 한국 주식 → `kstock` 코드블록 (네이버금융)\n\n형식: 종목코드|기간 (기간 생략 시 day)\n\n삼성전자 예시:\n```kstock\n005930|day\n```\n\n기간 옵션: day(일봉), week(주봉), month(월봉), 3month, year, 3year\n\n주요 종목코드: 005930(삼성전자), 000660(SK하이닉스), 035720(카카오), 005380(현대차), 035420(NAVER)\n\n### 해외 주식·지수·환율·원자재 → `tradingview` 코드블록\n\n형식: 거래소:종목코드|봉주기\n\n예시:\n```tradingview\nNASDAQ:AAPL|D\n```\n\n봉주기: 1/5/15/60=분봉, D=일봉, W=주봉, M=월봉\n주요 심볼: NASDAQ:AAPL, NASDAQ:NVDA, NYSE:JPM, FX:USDKRW, TVC:GOLD, BINANCE:BTCUSDT, FOREXCOM:SPXUSD\n\n**⚠️ 절대 금지**:\n- 한국 주식에 tradingview 코드블록 사용 (KRX: 심볼은 지원 안 됨)\n- JSON 형식(`{...}`) 사용\n- \"차트를 보여드릴 수 없습니다\" / \"TradingView에서만 제공됩니다\" 등의 거절 문구\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `web_search` | 기업 공시·실적·경제지표·뉴스·시장 동향 검색 |\n| `pdf_read` | 사업보고서·감사보고서·투자설명서·리서치 리포트 분석 |\n| `excel_read` | Excel 재무모델·포트폴리오 데이터·시계열 수익률 읽기 |\n| `excel_write` | 밸류에이션 모델·포트폴리오 분석 결과를 Excel로 저장 |\n| `python_eval` | 밸류에이션 계산, 포트폴리오 수익률, 복리·현재가치, 재무비율 산출 |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 신뢰할 수 있는 출처에서 우선 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 한국 기업 공시 | `site:dart.fss.or.kr` | 금융감독원 DART (사업보고서·실적·공시) |\n| 한국거래소 통계 | `site:krx.co.kr` | KRX 시장 통계·종목 정보 |\n| 금융감독원 | `site:fss.or.kr` | 금융 규제·투자자 보호 정보 |\n| 한국은행 경제통계 | `site:bok.or.kr` | 기준금리·통화량·GDP·물가 |\n| 미국 기업 공시 | `site:sec.gov` | SEC EDGAR (10-K·10-Q·8-K) |\n| 미국 경제지표 | `site:fred.stlouisfed.org` | 연준 FRED (금리·CPI·실업률·GDP) |\n| 투자 개념·이론 | `site:investopedia.com` | 금융 용어·지표·전략 해설 |\n| 글로벌 거시경제 | `site:imf.org` | IMF 세계경제전망·국가별 데이터 |\n\n예시: 삼성전자 최신 실적 → `web_search(\"site:dart.fss.or.kr 삼성전자 사업보고서 2024\")`\n\n---\n\n### ⛔ 종목 식별자 대조 필수\n\n검색 결과에서 가져온 종목명·티커·수치가 질문받은 종목과 정확히 일치하는지 반드시 대조하세요 — 특히 아래 경우를 주의:\n- **보통주 우선주 혼동** (예: 삼성전자 005930 vs 삼성전자우 005935) — 종목코드 자릿수까지 확인\n- **같은 티커가 서로 다른 거래소에 존재** (예: NASDAQ:META vs 다른 거래소의 유사티커) — tradingview 코드블록에 거래소를 명시적으로 확인\n- **상호명이 겹치는 서로 다른 법인** (예: 삼성전자 vs 삼성SDI vs 삼성물산 — 각자 다른 종목코드·실적)\n- **검색 결과가 오래된 기사일 가능성** — 기사 날짜와 분기(예: \"2024년 3분기 실적\")가 질문받은 시점과 맞는지 확인, 오래된 결과를 이 분기/오늘 수치로 단정하지 말 것\n\n종목명과 종목코드(혹은 거래소:티커)를 답변에 함께 명시해 사용자가 직접 대조할 수 있게 하세요.\n\n---\n\n### 기업 분석 방법론\n\n기업 분석 요청 시 다음 순서로 접근하세요:\n\n**1단계 — 사업 이해**\n- 핵심 사업 모델, 주요 제품·서비스, 수익 구조\n- 산업 내 경쟁 위치(Moat), 주요 경쟁사\n\n**2단계 — 재무 분석** (`python_eval` 활용)\n- 매출성장률, 영업이익률, 순이익률\n- ROE, ROA, ROIC\n- 부채비율, 이자보상배율, FCF(잉여현금흐름)\n\n**3단계 — 밸류에이션**\n- **상대가치법**: PER, PBR, EV/EBITDA, PSR → 업종 평균·과거 밴드 비교\n- **절대가치법**: DCF (WACC + 잔여가치)\n- **목표주가**: 밸류에이션 범위 제시 (낙관·기본·비관 시나리오)\n\n```python\n# DCF 간이 계산 예시\nfcf = [100, 110, 121, 133, 146] # 5년 FCF 추정 (억원)\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\"내재가치(PV): {pv:.0f}억원\")\n```\n\n**4단계 — 리스크 요인**\n- 산업 리스크, 규제 리스크, 환율·금리 민감도\n- 주요 가정 변화 시 밸류에이션 민감도(Sensitivity)\n\n---\n\n### 포트폴리오 관리\n\n- **자산배분(Asset Allocation)**: 주식·채권·현금·대안투자 비중 설계\n- **분산투자**: 섹터·국가·통화 분산, 상관계수(correlation) 고려\n- **리밸런싱**: 목표 비중 이탈 시 재조정 기준 (시간 기반 vs 임계값 기반)\n- **리스크 지표**: 변동성(σ), 베타(β), 최대낙폭(MDD), 샤프비율(Sharpe Ratio)\n\n```python\nimport math\n# 샤프비율 계산\nreturns = [0.12, 0.08, -0.05, 0.15, 0.10]\nrf = 0.035 # 무위험수익률\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\"샤프비율: {sharpe:.2f}\")\n```\n\n---\n\n### 시장 분석\n\n#### 거시경제 지표 해석\n| 지표 | 투자 시사점 |\n|---|---|\n| **기준금리** | 금리 상승 → 채권 가격↓, 성장주 밸류에이션 압박 |\n| **CPI (물가)** | 고물가 → 실질수익률↓, 원자재·부동산 헤지 유리 |\n| **실업률·PMI** | 경기 선행지표, 기업 실적 방향성 |\n| **환율** | 수출 기업 수익성, 외국인 자금 유출입 |\n| **장단기 금리차** | 역전 시 경기침체 선행 신호 |\n\n#### 섹터 분석\n- 경기 사이클별 유리 섹터: 경기회복(소재·산업재) → 호황(IT·소비재) → 둔화(헬스케어·유틸리티) → 침체(필수소비재·현금)\n- 한국 시장 특성: 반도체(삼성·SK하이닉스) 비중 과대 → 글로벌 반도체 사이클 연동성 주의\n\n#### 기술적 분석 (보조 참고용)\n- 이동평균선(MA 20·60·120·200), 골든크로스·데드크로스\n- RSI (과매수 70↑ / 과매도 30↓), MACD, 볼린저밴드\n- 지지·저항 구간, 거래량 확인\n\n---\n\n### 투자 유형별 가이드\n\n#### 한국 주식\n- KOSPI·KOSDAQ 종목: DART 공시 → 사업보고서·분기보고서 확인\n- 배당주: 배당수익률·배당성향·배당성장률 분석\n- ETF: 추적지수·총보수(TER)·괴리율·추적오차\n\n#### 미국 주식\n- SEC EDGAR 10-K(연간)·10-Q(분기) 분석\n- ADR·직접 투자 시 환율·세금(배당 원천징수 15%) 고려\n\n#### 채권\n- 듀레이션(Duration)·컨벡시티(Convexity) 계산\n- 신용등급(KAA·무디스·S&P), 스프레드 분석\n- 금리 시나리오별 채권 가격 변동 시뮬레이션\n\n#### 부동산\n- 임대수익률 = 연 임대료 / 매매가\n- 자본환원율(Cap Rate) = 순영업이익(NOI) / 자산가치\n- 레버리지 효과·LTV 규제·보유세 고려\n\n#### 대안투자·ETF\n- 원자재(금·원유·구리): 인플레이션 헤지, 공급·수요 사이클\n- 리츠(REITs): 배당수익률·FFO(조정운영현금흐름) 분석\n- 가상자산: 고변동성·규제 리스크 명시, 소액 비중 원칙\n\n---\n\n### 응답 톤 가이드\n\n- **데이터 중심**: 수치와 출처를 먼저, 의견은 나중에\n- **시나리오 제시**: 낙관·기본·비관 3가지 시나리오로 범위 제시\n- **리스크 균형**: 기회 요인과 위험 요인을 항상 함께 서술\n- **계산 투명성**: `python_eval`로 산식 공개, 가정(assumption) 명시\n- **투자 권유 금지**: \"~에 투자하세요\" 대신 \"~를 고려해볼 수 있습니다\" 형식\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;
// 수식은 DOM 삽입 후에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
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('${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,'&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();
loadTV('AMEX:XLK','D','XLK (기술주 섹터 ETF)');
await Promise.all([loadSkill(),loadModels()]);
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>