Files
homeclaw/web-ui/accountant-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

568 lines
44 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 ── */
.ac-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.ac-hdr h1{font-size:13px;font-weight:700;margin:0;}
.ac-hdr-spacer{flex:1;}
.ac-hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;transition:.15s;font-family:inherit;}
.ac-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 ── */
.ac-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.ac-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
.ac-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;}
.ac-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex-shrink:0;}
.ac-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ac-qbtn:hover{border-color:#22c55e;color:#22c55e;background:rgba(34,197,94,.05);}
.ac-divider{height:1px;background:var(--line);margin:4px 0;}
.ac-file-hdr{display:flex;align-items:center;padding:0 10px 3px;flex-shrink:0;}
.ac-file-hdr span{flex:1;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.ac-file-hdr button{background:none;border:none;padding:0;cursor:pointer;font-size:11px;color:var(--muted);transition:.12s;}
.ac-file-hdr button:hover{color:#22c55e;}
.ac-files{flex:1;overflow-y:auto;padding:0 4px;}
.ac-fi{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:5px;font-size:11px;color:var(--muted);transition:.12s;}
.ac-fi:hover{background:rgba(34,197,94,.07);color:var(--text);}
.ac-fi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
.ac-fi-btn{background:none;border:none;cursor:pointer;font-size:12px;padding:1px 3px;color:var(--muted);flex-shrink:0;border-radius:3px;transition:.12s;}
.ac-fi-btn:hover{color:#22c55e;}
.ac-upload{margin:6px;border:1px dashed var(--line);border-radius:6px;padding:5px;font-size:11px;color:var(--muted);cursor:pointer;text-align:center;background:none;font-family:inherit;transition:.15s;display:block;width:calc(100% - 12px);}
.ac-upload:hover{border-color:#22c55e;color:#22c55e;}
/* ── Center viewer ── */
.ac-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
.ac-center-hdr{display:flex;align-items:center;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;gap:8px;min-height:38px;}
.ac-center-title{font-size:12px;font-weight:700;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ac-center-btn{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;flex-shrink:0;}
.ac-center-btn:hover{border-color:#22c55e;color:#22c55e;}
.ac-center-body{flex:1;overflow:hidden;display:flex;flex-direction:column;}
.ac-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:10px;padding:40px 20px;text-align:center;}
.ac-empty .icon{font-size:48px;opacity:.35;}
.ac-empty p{font-size:12px;line-height:1.6;max-width:240px;}
.ac-viewer{flex:1;border:none;display:none;width:100%;min-height:0;}
.ac-viewer.visible{display:block;}
/* ── Resize handle ── */
.ac-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.ac-resize:hover,.ac-resize.dragging{background:#22c55e;}
/* ── Chat ── */
.ac-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.ac-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.ac-chat-label{font-size:10px;font-weight:700;color:#22c55e;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.ac-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;}
.ac-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.ac-chat-clear:hover{color:#ef4444;}
.ac-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.ac-msg{display:flex;flex-direction:column;}
.ac-msg.user{align-items:flex-end;}
.ac-msg.assistant{align-items:flex-start;}
.ac-msg.system{align-items:center;}
.ac-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:92%;word-break:break-word;}
.ac-msg.user .ac-bubble{background:#14532d;color:#dcfce7;border-radius:10px 10px 3px 10px;}
.ac-msg.assistant .ac-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.ac-msg.system .ac-bubble{background:transparent;color:var(--muted);font-size:11px;}
.ac-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.ac-bubble code{font-size:11px;}
.ac-bubble p{margin:3px 0;}
.ac-bubble h3{font-size:13px;margin:6px 0 3px;}
.ac-bubble ul,.ac-bubble ol{padding-left:16px;margin:3px 0;}
.ac-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
.ac-bubble th,.ac-bubble td{border:1px solid var(--line);padding:4px 7px;text-align:left;}
.ac-bubble th{background:var(--panel);font-weight:700;}
.ac-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;}
.ac-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
.ac-open-link{font-size:10px;color:#22c55e;cursor:pointer;text-decoration:underline;margin-top:2px;display:inline-block;}
.ac-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.ac-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:acDot 1.2s ease-in-out infinite;}
.ac-dot:nth-child(2){animation-delay:.2s;}.ac-dot:nth-child(3){animation-delay:.4s;}
@keyframes acDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.ac-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.ac-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;}
.ac-textarea:focus{border-color:#22c55e;}
.ac-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;}
.ac-send:hover:not(:disabled){background:#166534;}
.ac-send:disabled{opacity:.5;cursor:not-allowed;}
/* ── Drop overlay ── */
.ac-drop{position:fixed;inset:0;background:rgba(34,197,94,.12);border:3px dashed #22c55e;border-radius:12px;z-index:100;display:none;align-items:center;justify-content:center;font-size:18px;color:#22c55e;font-weight:700;pointer-events:none;}
.ac-drop.on{display:flex;}
/* ── Scrollbars ── */
.ac-files::-webkit-scrollbar,.ac-msgs::-webkit-scrollbar{width:3px;}
.ac-files::-webkit-scrollbar-thumb,.ac-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
/* ── Mobile ── */
@media(max-width:1024px){
.ac-resize{display:none;}
.ac-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);
}
.ac-sb.mob-open{transform:translateX(0);}
.ac-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);
}
.ac-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;}
.ac-center{width:100%;}
.ac-center-body{padding-bottom:64px;}
#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;}
.ac-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>
<div class="ac-drop" id="drop-overlay">📊 Excel 파일을 드롭하세요</div>
<header class="ac-hdr">
<a href="/" class="ac-hdr-btn">← 홈</a>
<h1>📊 회계 어시스턴트</h1>
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
<div class="ac-hdr-spacer"></div>
<button class="ac-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-files" onclick="mobToggle('files')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></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"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></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="ac-layout">
<!-- ── Sidebar ── -->
<aside class="ac-sb">
<div class="ac-sb-sec">빠른 요청</div>
<div class="ac-quick">
<button class="ac-qbtn" onclick="sendQuick('손익계산서를 Excel로 만들어주세요. 매출, 매출원가, 판관비, 영업이익, 순이익 항목을 포함해주세요.')">📈 손익계산서 작성</button>
<button class="ac-qbtn" onclick="sendQuick('부가가치세 신고서를 Excel로 만들어주세요. 매출세액, 매입세액, 납부세액을 계산해주세요.')">🧾 부가세 계산</button>
<button class="ac-qbtn" onclick="sendQuick('재무상태표(대차대조표)를 Excel로 만들어주세요. 자산, 부채, 자본으로 구분해주세요.')">📋 재무상태표 작성</button>
<button class="ac-qbtn" onclick="sendQuick('급여명세서를 Excel로 만들어주세요. 기본급, 4대보험, 세금, 실수령액을 계산해주세요.')">💰 급여명세서 작성</button>
<button class="ac-qbtn" onclick="sendQuick('업로드한 재무제표를 분석해주세요. 수익성·안정성·성장성 지표를 계산하고 종합 의견을 주세요.')">🔍 재무제표 분석</button>
</div>
<div class="ac-divider"></div>
<div class="ac-file-hdr">
<span>Excel 파일</span>
<button onclick="loadExcelFiles()" title="새로고침">↺</button>
</div>
<div class="ac-files" id="excel-file-list">
<div style="font-size:11px;color:var(--muted);padding:4px 6px">로딩 중...</div>
</div>
<label class="ac-upload">
⬆ Excel 업로드
<input type="file" accept=".xlsx,.xls,.csv" style="display:none" onchange="uploadExcelFile(this.files[0]);this.value=''">
</label>
</aside>
<!-- ── Center viewer ── -->
<section class="ac-center" id="ac-center">
<div class="ac-center-hdr">
<span class="ac-center-title" id="viewer-title">스프레드시트 뷰어</span>
<button class="ac-center-btn" id="viewer-new-btn" onclick="newSheet()" title="새 시트">+ 새 시트</button>
<button class="ac-center-btn" id="viewer-close-btn" onclick="closeViewer()" style="display:none">✕ 닫기</button>
</div>
<div class="ac-center-body" id="center-body">
<div class="ac-empty" id="viewer-empty">
<div class="icon">📊</div>
<p>사이드바에서 Excel 파일을 열거나<br>AI에게 표·분석 작성을 요청하면<br>여기에 스프레드시트가 열립니다.</p>
</div>
<iframe class="ac-viewer" id="xlsx-viewer" src="" allow="clipboard-read; clipboard-write"></iframe>
</div>
</section>
<!-- ── Resize handle ── -->
<div class="ac-resize" id="resize-handle"></div>
<!-- ── Chat ── -->
<div class="ac-chat" id="chat-panel">
<div class="ac-chat-hdr">
<span class="ac-chat-label">📊 AI</span>
<select class="ac-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="ac-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<div class="ac-msgs" id="chat-msgs"></div>
<div class="ac-input-bar">
<textarea class="ac-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="ac-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY = 'smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Mobile overlay ────────────────────────────────────────────────────────────
function mobCloseAll(){
document.querySelector('.ac-sb').classList.remove('mob-open');
document.getElementById('chat-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['files','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('.ac-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==='files'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-files').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='';}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
// ── Resize ────────────────────────────────────────────────────────────────────
(function(){
const CHAT_W='ac_chat_width';
const handle=document.getElementById('resize-handle');
const panel=document.getElementById('chat-panel');
let dragging=false,startX=0,startW=0;
const saved=parseInt(localStorage.getItem(CHAT_W)||'340',10);
panel.style.width=saved+'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;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
})();
// ── Model ─────────────────────────────────────────────────────────────────────
const MODEL_KEY='ac_model';
let selectedModel=localStorage.getItem(MODEL_KEY)||'';
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);
for(const m of models){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);}
if(!selectedModel)sel.value='';
}catch{}
}
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
// ── Skill ─────────────────────────────────────────────────────────────────────
const APP_KEY='ac';
let skillContext='';
let _sessionId=null;
function getSessionId(){return _sessionId||'ac_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='ac_'+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: Accountant\ndescription: \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34\ubd84\uc11d\u00b7\uacb0\uc0b0\u00b7\uc6d0\uac00\uad00\ub9ac\u00b7\ud22c\uc790\ud3c9\uac00\ub97c \uc544\uc6b0\ub974\ub294 \uacf5\uc778\ud68c\uacc4\uc0ac \uc5b4\uc2dc\uc2a4\ud134\ud2b8. K-IFRS, \uc138\ubc95, \ubc95\uc778\u00b7\uac1c\uc778 \uc138\ubb34 \uc804\ubc18 \uc9c0\uc6d0.\nemoji: \"\ud83d\udcca\"\nversion: 1.0.0\ntriggers: [\uc138\uae08, \uc138\ubb34, \ud68c\uacc4, \uc7ac\ubb34, \uacb0\uc0b0, \ubd80\uac00\uc138, \uc18c\ub4dd\uc138, \ubc95\uc778\uc138, \ube44\uc6a9, \uc190\uc775, \uc790\uc0b0, \ubd80\ucc44, \uac10\uac00\uc0c1\uac01, \ubd84\uac1c, \uc7a5\ubd80, \uc6d0\uac00, \uc808\uc138, \ud658\uae09, \uc2e0\uace0, \ub0a9\ubd80, \uacf5\uc81c, \ub9e4\ucd9c, \uc601\uc218\uc99d, \uc138\uae08\uacc4\uc0b0\uc11c]\n---\n\n\ub2f9\uc2e0\uc740 \uacf5\uc778\ud68c\uacc4\uc0ac(CPA) \uc218\uc900\uc758 \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34 \uc804\ubb38 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uc815\ud655\ud558\uace0 \uc2e4\uc6a9\uc801\uc778 \ub2f5\ubcc0\uc744 \uc81c\uacf5\ud558\uba70, \ubcf5\uc7a1\ud55c \uac1c\ub150\uc740 \uc27d\uac8c \ud480\uc5b4 \uc124\uba85\ud558\uc138\uc694. \ud68c\uacc4\u00b7\uc138\ubb34 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4\ub97c \uad04\ud638\uc5d0 \ubcd1\uae30\ud558\uc138\uc694. (\uc608: \uac10\uac00\uc0c1\uac01(depreciation))\n\n---\n\n### \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ub3c4\uad6c\n\n| \ub3c4\uad6c | \ud65c\uc6a9 \uc0c1\ud669 |\n|---|---|\n| `web_search` | \uc138\ubc95 \uac1c\uc815\uc0ac\ud56d\u00b7\uad6d\uc138\uccad \uace0\uc2dc\u00b7\ud68c\uacc4\uae30\uc900 \ubcc0\uacbd\u00b7\ud310\ub840\u00b7\uc138\uc728\ud45c \ucd5c\uc2e0 \uc815\ubcf4 \uac80\uc0c9 |\n| `pdf_read` | \uc7ac\ubb34\uc81c\ud45c\u00b7\uc138\uae08\uacc4\uc0b0\uc11c\u00b7\uacc4\uc57d\uc11c\u00b7\uac10\uc0ac\ubcf4\uace0\uc11c PDF \ubd84\uc11d |\n| `excel_read` | Excel \uc7ac\ubb34\uc81c\ud45c\u00b7\uc6d0\uc7a5\u00b7\uc138\uae08\uacc4\uc0b0\uc11c \ub370\uc774\ud130 \uc77d\uae30 (\uc2dc\ud2b8 \uc9c0\uc815, \ubc94\uc704 \uc9c0\uc815 \uac00\ub2a5) |\n| `excel_write` | \uc138\uc561 \uacc4\uc0b0 \uacb0\uacfc\u00b7\uc7ac\ubb34\ubd84\uc11d \ubcf4\uace0\uc11c\ub97c Excel \ud30c\uc77c\ub85c \uc800\uc7a5 |\n| `python_eval` | \uc138\uc561 \uacc4\uc0b0, \uc7ac\ubb34\ube44\uc728 \uc0b0\ucd9c, \uc190\uc775\ubd84\uc11d, \ud604\uae08\ud750\ub984 \ucd94\uc815, \ubcf5\ub9ac \uacc4\uc0b0 |\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 \uacf5\uc2e0\ub825 \uc788\ub294 \uc138\ubb34\u00b7\ud68c\uacc4 \uc815\ubcf4\ub97c \uc6b0\uc120 \uac80\uc0c9\ud558\uc138\uc694.\n\n| \uc8fc\uc81c | site: \uc5f0\uc0b0\uc790 | \uc124\uba85 |\n|---|---|---|\n| \uc138\ubc95 \uc6d0\ubb38\u00b7\uc720\uad8c\ud574\uc11d | `site:taxlaw.nts.go.kr` | \uad6d\uc138\ubc95\ub839\uc815\ubcf4\uc2dc\uc2a4\ud15c (\ubc95\ub839\u00b7\uc608\uaddc\u00b7\ud310\ub840) |\n| \uad6d\uc138\uccad \uacf5\uc2dd \uc548\ub0b4 | `site:nts.go.kr` | \uc138\uc728\ud45c\u00b7\uc2e0\uace0 \ubc29\ubc95\u00b7\uac01\uc885 \uace0\uc2dc |\n| \uae30\uc5c5 \uacf5\uc2dc (\uc7ac\ubb34\uc81c\ud45c) | `site:dart.fss.or.kr` | \uae08\uc735\uac10\ub3c5\uc6d0 DART \uc804\uc790\uacf5\uc2dc |\n| K-IFRS \ud68c\uacc4\uae30\uc900 | `site:kasb.or.kr` | \ud55c\uad6d\ud68c\uacc4\uae30\uc900\uc6d0 \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uae30\ud68d\uc7ac\uc815\ubd80 \uc815\ucc45 | `site:moef.go.kr` | \uc138\uc81c \uac1c\ud3b8\uc548\u00b7\uc608\uc0b0\u00b7\uc7ac\uc815 \uc815\ucc45 |\n| \uad6d\uc81c IFRS \uae30\uc900 | `site:ifrs.org` | IFRS Foundation \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uad00\uc138\u00b7\ubb34\uc5ed | `site:customs.go.kr` | \uad00\uc138\uccad (\uc218\ucd9c\uc785 \uc138\uc728\u00b7\ud488\ubaa9\ubd84\ub958) |\n\n\uc608\uc2dc: 2024\ub144 \ubc95\uc778\uc138\uc728 \ubcc0\uacbd \u2192 `web_search(\"site:nts.go.kr \ubc95\uc778\uc138\uc728 2024 \uac1c\uc815\")`\n\n---\n\n### \uc138\ubb34 \uc0c1\ub2f4\n\n#### \uac1c\uc778 \uc138\uae08\n- **\uc885\ud569\uc18c\ub4dd\uc138**: \uc18c\ub4dd \uc885\ub958\ubcc4 \ubd84\ub958(\uadfc\ub85c\u00b7\uc0ac\uc5c5\u00b7\uc774\uc790\u00b7\ubc30\ub2f9\u00b7\uc5f0\uae08\u00b7\uae30\ud0c0), \uacf5\uc81c \ud56d\ubaa9 \ucd5c\uc801\ud654\n- **\uc591\ub3c4\uc18c\ub4dd\uc138**: \ubd80\ub3d9\uc0b0\u00b7\uc8fc\uc2dd\u00b7\ube44\uc0c1\uc7a5\uc8fc\uc2dd \uc591\ub3c4\ucc28\uc775 \uacc4\uc0b0, \ube44\uacfc\uc138\u00b7\uac10\uba74 \uc694\uac74\n- **\uc0c1\uc18d\u00b7\uc99d\uc5ec\uc138**: \uacfc\uc138\uac00\uc561 \uacc4\uc0b0, \uacf5\uc81c \ud55c\ub3c4, \uc2e0\uace0 \uae30\ud55c(3\uac1c\uc6d4/6\uac1c\uc6d4)\n- **\ubd80\uac00\uac00\uce58\uc138**: \uc77c\ubc18\u00b7\uac04\uc774\uacfc\uc138\uc790 \uad6c\ubd84, \ub9e4\uc785\uc138\uc561 \uacf5\uc81c, \uc2e0\uace0 \uc8fc\uae30\n- **\uae08\uc735\uc18c\ub4dd \uc885\ud569\uacfc\uc138**: 2\ucc9c\ub9cc \uc6d0 \ucd08\uacfc \uc2dc \ud569\uc0b0 \uae30\uc900, \uc6d0\ucc9c\uc9d5\uc218 \uc138\uc728\n\n#### \ubc95\uc778 \uc138\uae08\n- **\ubc95\uc778\uc138**: \uacfc\uc138\ud45c\uc900 \uacc4\uc0b0, \uc138\uc728 \uad6c\uac04(2\uc5b5 \uc774\ud558 9%\u00b72\uc5b5~200\uc5b5 19%\u00b7200\uc5b5~3\ucc9c\uc5b5 21%\u00b7\ucd08\uacfc 24%), \uc138\uc561\uacf5\uc81c\u00b7\uac10\uba74\n- **\uc6d0\ucc9c\uc9d5\uc218**: \uc778\uac74\ube44\u00b7\uc6a9\uc5ed\ube44\u00b7\uc774\uc790\u00b7\ubc30\ub2f9 \uc6d0\ucc9c\uc138 \uc138\uc728\ud45c\n- **\uc774\uc804\uac00\uaca9**: \ud2b9\uc218\uad00\uacc4\uc790 \uac70\ub798 \uc815\uc0c1\uac00\uaca9 \uc0b0\uc815 \ubc29\ubc95\n- **\uc138\ubb34\uc870\uc0ac**: \ub300\ube44 \uc11c\ub958, \uc18c\uba85 \ubc29\ubc95, \ubd88\ubcf5 \uc808\ucc28(\uc774\uc758\uc2e0\uccad \u2192 \uc2ec\ud310\uccad\uad6c \u2192 \ud589\uc815\uc18c\uc1a1)\n\n\uc138\uae08 \uacc4\uc0b0 \uc2dc `python_eval`\ub85c \uc815\ud655\ud55c \uc0b0\uc2dd\uc744 \uc81c\uc2dc\ud558\uc138\uc694:\n```python\n# \uc608: \uc591\ub3c4\uc18c\ub4dd\uc138 \uacc4\uc0b0\n\uc591\ub3c4\uac00\uc561 = ...\n\ucde8\ub4dd\uac00\uc561 = ...\n\ud544\uc694\uacbd\ube44 = ...\n\uc591\ub3c4\ucc28\uc775 = \uc591\ub3c4\uac00\uc561 - \ucde8\ub4dd\uac00\uc561 - \ud544\uc694\uacbd\ube44\n\uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c = ...\n\uacfc\uc138\ud45c\uc900 = \uc591\ub3c4\ucc28\uc775 - \uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c - \uae30\ubcf8\uacf5\uc81c(2_500_000)\n\uc138\uc728 = ...\n\uc0b0\ucd9c\uc138\uc561 = \uacfc\uc138\ud45c\uc900 * \uc138\uc728\n```\n\n---\n\n### \uc7ac\ubb34\uc81c\ud45c \ubd84\uc11d\n\n\uc7ac\ubb34\uc81c\ud45c(\uc190\uc775\uacc4\uc0b0\uc11c\u00b7\uc7ac\ubb34\uc0c1\ud0dc\ud45c\u00b7\ud604\uae08\ud750\ub984\ud45c)\uac00 \uc81c\uacf5\ub418\uba74 \ub2e4\uc74c \uc21c\uc11c\ub85c \ubd84\uc11d\ud558\uc138\uc694:\n\n1. **\uc218\uc775\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\ucd1d\uc774\uc775\ub960(Gross Margin) = \ub9e4\ucd9c\ucd1d\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc601\uc5c5\uc774\uc775\ub960(Operating Margin) = \uc601\uc5c5\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc21c\uc774\uc775\ub960(Net Margin) = \ub2f9\uae30\uc21c\uc774\uc775 / \ub9e4\ucd9c\uc561\n - ROE = \ub2f9\uae30\uc21c\uc774\uc775 / \uc790\uae30\uc790\ubcf8, ROA = \ub2f9\uae30\uc21c\uc774\uc775 / \ucd1d\uc790\uc0b0\n\n2. **\uc548\uc815\uc131 \ubd84\uc11d**\n - \uc720\ub3d9\ube44\uc728 = \uc720\ub3d9\uc790\uc0b0 / \uc720\ub3d9\ubd80\ucc44 (200% \uc774\uc0c1 \uc591\ud638)\n - \ubd80\ucc44\ube44\uc728 = \ubd80\ucc44 / \uc790\uae30\uc790\ubcf8 (100% \uc774\ud558 \uc548\uc815)\n - \uc774\uc790\ubcf4\uc0c1\ubc30\uc728 = \uc601\uc5c5\uc774\uc775 / \uc774\uc790\ube44\uc6a9 (3\ubc30 \uc774\uc0c1 \uad8c\uace0)\n\n3. **\uc131\uc7a5\uc131\u00b7\ud6a8\uc728\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\uc131\uc7a5\ub960, \uc7ac\uace0\uc790\uc0b0\ud68c\uc804\uc728, \ub9e4\ucd9c\ucc44\uad8c\ud68c\uc804\uc728\n\n4. **\ud604\uae08\ud750\ub984 \ubd84\uc11d**\n - \uc601\uc5c5/\ud22c\uc790/\uc7ac\ubb34 \ud604\uae08\ud750\ub984 \uad6c\uc870, FCF(\uc789\uc5ec\ud604\uae08\ud750\ub984) \uacc4\uc0b0\n\n5. **\uc885\ud569 \uc758\uacac** \u2014 \uc7ac\ubb34 \uac74\uc804\uc131 \ud3c9\uac00 + \uc8fc\uc758 \ud56d\ubaa9 + \uac1c\uc120 \uc81c\uc548\n\n---\n\n### \ud68c\uacc4\ucc98\ub9ac (\ubd84\uac1c\u00b7\uae30\uc7a5)\n\n\ud68c\uacc4\ucc98\ub9ac \uc9c8\ubb38 \uc2dc:\n- **\ubd84\uac1c \ud615\uc2dd**: `(\ucc28) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561 / (\ub300) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561`\n- **\uc801\uc6a9 \uae30\uc900**: K-IFRS(\uc0c1\uc7a5\u00b7\ub300\ud615) \ub610\ub294 \uc77c\ubc18\uae30\uc5c5\ud68c\uacc4\uae30\uc900(K-GAAP, \uc911\uc18c\uae30\uc5c5)\n- **\uc8fc\uc694 \ud56d\ubaa9**: \uac10\uac00\uc0c1\uac01, \ucda9\ub2f9\ubd80\ucc44, \ub9ac\uc2a4(IFRS 16), \uc218\uc775\uc778\uc2dd(IFRS 15), \uae08\uc735\uc0c1\ud488(IFRS 9)\n\n\uc608\uc2dc:\n```\n\ube44\ud488 \ucde8\ub4dd (\ubd80\uac00\uc138 \ud3ec\ud568)\n(\ucc28) \ube44\ud488 1,000,000\n \ubd80\uac00\uc138\ub300\uae09\uae08 100,000\n(\ub300) \ubcf4\ud1b5\uc608\uae08 1,100,000\n```\n\n---\n\n### \uc6d0\uac00 \uad00\ub9ac / \uad00\ub9ac\ud68c\uacc4\n\n- **\uc6d0\uac00 \ubd84\ub958**: \ubcc0\ub3d9\ube44\u00b7\uace0\uc815\ube44, \uc9c1\uc811\ube44\u00b7\uac04\uc811\ube44, \uc81c\uc870\uc6d0\uac00\u00b7\ud310\uad00\ube44\n- **\uc190\uc775\ubd84\uae30\uc810(BEP)**: BEP = \uace0\uc815\ube44 / (\ub2e8\uc704 \ud310\ub9e4\uac00 - \ub2e8\uc704 \ubcc0\ub3d9\ube44)\n- **CVP \ubd84\uc11d**: \ubaa9\ud45c\uc774\uc775 \ub2ec\uc131 \ud310\ub9e4\ub7c9 \uacc4\uc0b0\n- **\uc608\uc0b0 \ub300\ube44 \uc2e4\uc801(Budget vs. Actual)**: \ucc28\uc774 \uc6d0\uc778 \ubd84\uc11d(\uac00\uaca9\ucc28\uc774\u00b7\uc218\ub7c9\ucc28\uc774)\n- **ABC \uc6d0\uac00\uacc4\uc0b0**: \ud65c\ub3d9 \uae30\ubc18 \uc6d0\uac00 \ubc30\ubd80\n\n---\n\n### \ud22c\uc790\u00b7\uac00\uce58\ud3c9\uac00\n\n- **DCF(\ud604\uae08\ud750\ub984\ud560\uc778\ubc95)**: WACC \uc0b0\ucd9c, \uc794\uc5ec\uac00\uce58(Terminal Value) \uacc4\uc0b0\n- **\uba40\ud2f0\ud50c \ubc38\ub958\uc5d0\uc774\uc158**: PER, PBR, EV/EBITDA, PSR\n- **IRR\u00b7NPV**: \ud22c\uc790 \ud0c0\ub2f9\uc131 \ud310\ub2e8\n- **\ubd80\ub3d9\uc0b0 \ud22c\uc790 \uc218\uc775\ub960**: \uc784\ub300\uc218\uc775\ub960, \uc790\ubcf8\ud658\uc6d0\uc728(Cap Rate)\n\n---\n\n### \ucc3d\uc5c5\u00b7\uc0ac\uc5c5 \ud68c\uacc4 \uc2e4\ubb34\n\n- **\uc0ac\uc5c5\uc790 \uc720\ud615 \uc120\ud0dd**: \uac1c\uc778\uc0ac\uc5c5\uc790 vs \ubc95\uc778 \uc124\ub9bd (\uc138\uae08\u00b7\ucc45\uc784\u00b7\ube44\uc6a9 \ube44\uad50)\n- **\uac04\uc774\uacfc\uc138 vs \uc77c\ubc18\uacfc\uc138**: \uc5f0 \ub9e4\ucd9c 8\ucc9c\ub9cc \uc6d0 \uae30\uc900, \uc5c5\uc885\ubcc4 \ubd80\uac00\uc728\n- **4\ub300 \ubcf4\ud5d8**: \uc9c1\uc6d0 \uace0\uc6a9 \uc2dc \uc0ac\uc5c5\uc8fc \ubd80\ub2f4 \ubcf4\ud5d8\ub8cc \uacc4\uc0b0\n- **\uac00\uc0b0\uc138 \uc8fc\uc758**: \ubb34\uc2e0\uace0\u00b7\uacfc\uc18c\uc2e0\uace0\u00b7\ub0a9\ubd80\ubd88\uc131\uc2e4 \uac00\uc0b0\uc138 \uc138\uc728\n- **\uc804\uc790\uc138\uae08\uacc4\uc0b0\uc11c**: \ubc1c\ud589 \uc758\ubb34, \uc9c0\uc5f0\ubc1c\ud589 \uac00\uc0b0\uc138, \uc218\uc815\ubc1c\ud589 \ubc29\ubc95\n\n---\n\n### \uc751\ub2f5 \ud1a4 \uac00\uc774\ub4dc\n\n- **\uc815\ud655\u00b7\uc2e0\ub8b0\uac10**: \uc218\uce58\uc640 \uc138\uc728\uc740 \uc815\ud655\ud558\uac8c, \ubd88\ud655\uc2e4\ud55c \uacbd\uc6b0 \"\ucd5c\uc2e0 \uc138\ubc95 \ud655\uc778 \uad8c\uc7a5\" \uba85\uc2dc\n- **\uc2e4\uc6a9\uc801**: \uc774\ub860\ubcf4\ub2e4 \"\uc2e4\uc81c\ub85c \uc5bc\ub9c8\" \"\uc5b4\ub514\uc5d0 \uc2e0\uace0\" \"\uc5b8\uc81c\uae4c\uc9c0\" \uc704\uc8fc\n- **\ub2e8\uacc4\ubcc4 \uc548\ub0b4**: \ubcf5\uc7a1\ud55c \uc138\ubb34 \uc774\uc288\ub294 \u2460 \u2461 \u2462 \uc21c\uc11c\ub85c \uc815\ub9ac\n- **\uacc4\uc0b0\uc740 \ucf54\ub4dc\ub85c**: \uc138\uc561 \uacc4\uc0b0\uc774 \ud3ec\ud568\ub41c \uacbd\uc6b0 `python_eval`\ub85c \ud22c\uba85\ud558\uac8c \uc0b0\uc2dd \uacf5\uac1c\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uc815\ubcf4 \uc81c\uacf5 \ubaa9\uc801\uc774\uba70 \uacf5\uc2dd \uc138\ubb34\u00b7\ubc95\ub960 \uc790\ubb38\uc744 \ub300\uccb4\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc911\uc694\ud55c \uacb0\uc815 \uc804 \ub2f4\ub2f9 \uc138\ubb34\uc0ac\u00b7\ud68c\uacc4\uc0ac\uc5d0\uac8c \ud655\uc778\ud558\uc138\uc694.\n";
function loadSkill(){
skillContext=SKILL_CONTENT;
document.getElementById('skill-badge').textContent='📊 회계 전문가 활성';
}
// ── Excel file list ───────────────────────────────────────────────────────────
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escAttr(s){return String(s||'').replace(/'/g,"\\'");}
async function loadExcelFiles(){
const el=document.getElementById('excel-file-list');
try{
const r=await fetch('/api/excel/list',{headers:authH()});
const data=await r.json();
if(!data.files||!data.files.length){el.innerHTML='<div style="font-size:11px;color:var(--muted);padding:4px 6px">파일 없음</div>';return;}
el.innerHTML=data.files.map(f=>`
<div class="ac-fi">
<span class="ac-fi-name" onclick="openViewer('${escAttr(f.apiPath)}','${escAttr(f.name)}')" title="${escHtml(f.relPath)}">${escHtml(f.name)}</span>
<button class="ac-fi-btn" onclick="openViewer('${escAttr(f.apiPath)}','${escAttr(f.name)}')" title="열기">📂</button>
</div>`).join('');
}catch{el.innerHTML='<div style="font-size:11px;color:var(--err);padding:4px 6px">로드 실패</div>';}
}
// ── Viewer ────────────────────────────────────────────────────────────────────
function openViewer(apiPath, name){
const iframe=document.getElementById('xlsx-viewer');
const empty=document.getElementById('viewer-empty');
const closeBtn=document.getElementById('viewer-close-btn');
const title=document.getElementById('viewer-title');
empty.style.display='none';
iframe.src='/xlsx-viewer.html?file='+encodeURIComponent(apiPath)+'&save='+encodeURIComponent(apiPath)+'&title='+encodeURIComponent(name);
iframe.classList.add('visible');
title.textContent=name;
closeBtn.style.display='';
}
function closeViewer(){
const iframe=document.getElementById('xlsx-viewer');
iframe.src='';iframe.classList.remove('visible');
document.getElementById('viewer-empty').style.display='';
document.getElementById('viewer-title').textContent='스프레드시트 뷰어';
document.getElementById('viewer-close-btn').style.display='none';
}
function newSheet(){
openViewer('/api/files/__new__.xlsx','새 시트.xlsx');
}
async function uploadExcelFile(file){
if(!file)return;
addMsg('system','📊 '+escHtml(file.name)+' 업로드 중...');
try{
const form=new FormData();form.append('file',file);
const r=await fetch('/api/upload',{method:'POST',headers:authH(),body:form});
if(!r.ok)throw new Error('업로드 실패 '+r.status);
const data=await r.json();
const apiPath=data.url||data.path||'/api/files/'+file.name;
openViewer(apiPath,file.name);
addMsg('system','✅ '+escHtml(file.name)+' 열기 완료');
setTimeout(loadExcelFiles,1000);
}catch(e){
// Fallback: create object URL and open locally
const url=URL.createObjectURL(file);
openViewer(url,file.name);
addMsg('system','📂 '+escHtml(file.name)+' (로컬 열기)');
}
}
// Detect xlsx file path in AI response and auto-open
function detectAndOpenXlsx(text){
// Look for patterns like "saved: path.xlsx", "/path/to/file.xlsx", "파일: xxx.xlsx"
const patterns=[
/Excel file saved[^|]*\|\s*Sheet[^|]*\|\s*Rows[^|]*/, // tool result
/([^\s"'`]+\.xlsx)/gi,
];
const m=text.match(/([^\s"'`((]+\.xlsx)/i);
if(!m)return;
const raw=m[1];
// Build API path: if it looks like a workspace-relative path, try /api/files/
let apiPath=null;
if(raw.startsWith('/api/files/'))apiPath=raw;
else if(!raw.startsWith('/'))apiPath='/api/files/'+raw;
else apiPath='/api/files'+raw;
const name=raw.split('/').pop()||raw;
// Small delay to let server finish writing
setTimeout(()=>{
fetch(apiPath,{headers:authH(),method:'HEAD'}).then(r=>{
if(r.ok){openViewer(apiPath,name);loadExcelFiles();}
}).catch(()=>{});
},800);
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='ac-msg '+role;
const b=document.createElement('div');b.className='ac-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){
return text
.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(/^\|(.+)\|$/gm,row=>{
const cells=row.slice(1,-1).split('|').map(c=>`<td>${c.trim()}</td>`).join('');
return `<tr>${cells}</tr>`;
})
.replace(/(<tr>.*?<\/tr>)/gs, tbl=>{
const rows=tbl.match(/<tr>.*?<\/tr>/gs)||[];
if(!rows.length)return tbl;
const header=rows[0].replace(/<td>/g,'<th>').replace(/<\/td>/g,'</th>');
const body=rows.slice(2).join('');
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
})
.replace(/^-{3,}$/gm,'<hr>')
.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>(<table>)/g,'$1').replace(/(<\/table>)<\/p>/g,'$1')
.replace(/<p>(<hr>)/g,'$1').replace(/(<hr>)<\/p>/g,'$1');
}
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();
document.getElementById('chat-input').value=msg;
sendMessage();
}
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='ac-msg assistant';
typingDiv.innerHTML='<div class="ac-typing"><div class="ac-dot"></div><div class="ac-dot"></div><div class="ac-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
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='ac-msg assistant';
bubble=document.createElement('div');bubble.className='ac-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="ac-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
// Auto-open xlsx if AI generated one
if(textBuf)detectAndOpenXlsx(textBuf);
if(textBuf&&/\.xlsx/i.test(textBuf))setTimeout(loadExcelFiles,1500);
}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='ac_'+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';}
// ── Drag-and-drop ─────────────────────────────────────────────────────────────
document.addEventListener('dragover',e=>{
if([...e.dataTransfer.items].some(i=>/xlsx|xls|csv/.test(i.type)||i.type==='')){e.preventDefault();document.getElementById('drop-overlay').classList.add('on');}
});
document.addEventListener('dragleave',e=>{if(!e.relatedTarget||e.relatedTarget===document.documentElement)document.getElementById('drop-overlay').classList.remove('on');});
document.addEventListener('drop',e=>{
e.preventDefault();document.getElementById('drop-overlay').classList.remove('on');
const files=[...e.dataTransfer.files].filter(f=>/\.(xlsx|xls|csv)$/i.test(f.name));
if(files.length)uploadExcelFile(files[0]);
});
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
const ok=await checkAuth();if(!ok)return;
await initSessionId();
await loadHistory();
await Promise.all([loadSkill(),loadModels(),loadExcelFiles()]);
addMsg('system','📊 회계 어시스턴트입니다. 세무·회계·재무분석을 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>