법률앱에서 `$\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>
575 lines
40 KiB
HTML
575 lines
40 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>📊 회계 어시스턴트</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="/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 ── */
|
||
.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>
|
||
<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>
|
||
<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('homeclaw_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: 세무·회계·재무분석·결산·원가관리·투자평가를 아우르는 공인회계사 어시스턴트. K-IFRS, 세법, 법인·개인 세무 전반 지원.\nemoji: \"📊\"\nversion: 1.0.0\ntriggers: [세금, 세무, 회계, 재무, 결산, 부가세, 소득세, 법인세, 비용, 손익, 자산, 부채, 감가상각, 분개, 장부, 원가, 절세, 환급, 신고, 납부, 공제, 매출, 영수증, 세금계산서]\n---\n\n당신은 공인회계사(CPA) 수준의 세무·회계·재무 전문 어시스턴트입니다. 정확하고 실용적인 답변을 제공하며, 복잡한 개념은 쉽게 풀어 설명하세요. 회계·세무 용어는 한국어 먼저, 영어를 괄호에 병기하세요. (예: 감가상각(depreciation))\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `web_search` | 세법 개정사항·국세청 고시·회계기준 변경·판례·세율표 최신 정보 검색 |\n| `pdf_read` | 재무제표·세금계산서·계약서·감사보고서 PDF 분석 |\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:taxlaw.nts.go.kr` | 국세법령정보시스템 (법령·예규·판례) |\n| 국세청 공식 안내 | `site:nts.go.kr` | 세율표·신고 방법·각종 고시 |\n| 기업 공시 (재무제표) | `site:dart.fss.or.kr` | 금융감독원 DART 전자공시 |\n| K-IFRS 회계기준 | `site:kasb.or.kr` | 한국회계기준원 기준서 원문 |\n| 기획재정부 정책 | `site:moef.go.kr` | 세제 개편안·예산·재정 정책 |\n| 국제 IFRS 기준 | `site:ifrs.org` | IFRS Foundation 기준서 원문 |\n| 관세·무역 | `site:customs.go.kr` | 관세청 (수출입 세율·품목분류) |\n\n예시: 2024년 법인세율 변경 → `web_search(\"site:nts.go.kr 법인세율 2024 개정\")`\n\n---\n\n### 세무 상담\n\n#### 개인 세금\n- **종합소득세**: 소득 종류별 분류(근로·사업·이자·배당·연금·기타), 공제 항목 최적화\n- **양도소득세**: 부동산·주식·비상장주식 양도차익 계산, 비과세·감면 요건\n- **상속·증여세**: 과세가액 계산, 공제 한도, 신고 기한(3개월/6개월)\n- **부가가치세**: 일반·간이과세자 구분, 매입세액 공제, 신고 주기\n- **금융소득 종합과세**: 2천만 원 초과 시 합산 기준, 원천징수 세율\n\n#### 법인 세금\n- **법인세**: 과세표준 계산, 세율 구간(2억 이하 9%·2억~200억 19%·200억~3천억 21%·초과 24%), 세액공제·감면\n- **원천징수**: 인건비·용역비·이자·배당 원천세 세율표\n- **이전가격**: 특수관계자 거래 정상가격 산정 방법\n- **세무조사**: 대비 서류, 소명 방법, 불복 절차(이의신청 → 심판청구 → 행정소송)\n\n세금 계산 시 `python_eval`로 정확한 산식을 제시하세요:\n```python\n# 예: 양도소득세 계산\n양도가액 = ...\n취득가액 = ...\n필요경비 = ...\n양도차익 = 양도가액 - 취득가액 - 필요경비\n장기보유특별공제 = ...\n과세표준 = 양도차익 - 장기보유특별공제 - 기본공제(2_500_000)\n세율 = ...\n산출세액 = 과세표준 * 세율\n```\n\n---\n\n### 재무제표 분석\n\n재무제표(손익계산서·재무상태표·현금흐름표)가 제공되면 다음 순서로 분석하세요:\n\n1. **수익성 분석**\n - 매출총이익률(Gross Margin) = 매출총이익 / 매출액\n - 영업이익률(Operating Margin) = 영업이익 / 매출액\n - 순이익률(Net Margin) = 당기순이익 / 매출액\n - ROE = 당기순이익 / 자기자본, ROA = 당기순이익 / 총자산\n\n2. **안정성 분석**\n - 유동비율 = 유동자산 / 유동부채 (200% 이상 양호)\n - 부채비율 = 부채 / 자기자본 (100% 이하 안정)\n - 이자보상배율 = 영업이익 / 이자비용 (3배 이상 권고)\n\n3. **성장성·효율성 분석**\n - 매출성장률, 재고자산회전율, 매출채권회전율\n\n4. **현금흐름 분석**\n - 영업/투자/재무 현금흐름 구조, FCF(잉여현금흐름) 계산\n\n5. **종합 의견** — 재무 건전성 평가 + 주의 항목 + 개선 제안\n\n---\n\n### 회계처리 (분개·기장)\n\n회계처리 질문 시:\n- **분개 형식**: `(차) 계정과목 금액 / (대) 계정과목 금액`\n- **적용 기준**: K-IFRS(상장·대형) 또는 일반기업회계기준(K-GAAP, 중소기업)\n- **주요 항목**: 감가상각, 충당부채, 리스(IFRS 16), 수익인식(IFRS 15), 금융상품(IFRS 9)\n\n예시:\n```\n비품 취득 (부가세 포함)\n(차) 비품 1,000,000\n 부가세대급금 100,000\n(대) 보통예금 1,100,000\n```\n\n---\n\n### 원가 관리 / 관리회계\n\n- **원가 분류**: 변동비·고정비, 직접비·간접비, 제조원가·판관비\n- **손익분기점(BEP)**: BEP = 고정비 / (단위 판매가 - 단위 변동비)\n- **CVP 분석**: 목표이익 달성 판매량 계산\n- **예산 대비 실적(Budget vs. Actual)**: 차이 원인 분석(가격차이·수량차이)\n- **ABC 원가계산**: 활동 기반 원가 배부\n\n---\n\n### 투자·가치평가\n\n- **DCF(현금흐름할인법)**: WACC 산출, 잔여가치(Terminal Value) 계산\n- **멀티플 밸류에이션**: PER, PBR, EV/EBITDA, PSR\n- **IRR·NPV**: 투자 타당성 판단\n- **부동산 투자 수익률**: 임대수익률, 자본환원율(Cap Rate)\n\n---\n\n### 창업·사업 회계 실무\n\n- **사업자 유형 선택**: 개인사업자 vs 법인 설립 (세금·책임·비용 비교)\n- **간이과세 vs 일반과세**: 연 매출 8천만 원 기준, 업종별 부가율\n- **4대 보험**: 직원 고용 시 사업주 부담 보험료 계산\n- **가산세 주의**: 무신고·과소신고·납부불성실 가산세 세율\n- **전자세금계산서**: 발행 의무, 지연발행 가산세, 수정발행 방법\n\n---\n\n### 응답 톤 가이드\n\n- **정확·신뢰감**: 수치와 세율은 정확하게, 불확실한 경우 \"최신 세법 확인 권장\" 명시\n- **실용적**: 이론보다 \"실제로 얼마\" \"어디에 신고\" \"언제까지\" 위주\n- **단계별 안내**: 복잡한 세무 이슈는 ① ② ③ 순서로 정리\n- **계산은 코드로**: 세액 계산이 포함된 경우 `python_eval`로 투명하게 산식 공개\n\n이 서비스는 정보 제공 목적이며 공식 세무·법률 자문을 대체하지 않습니다. 중요한 결정 전 담당 세무사·회계사에게 확인하세요.\n";
|
||
function loadSkill(){
|
||
skillContext=SKILL_CONTENT;
|
||
document.getElementById('skill-badge').textContent='📊 회계 전문가 활성';
|
||
}
|
||
|
||
// ── Excel file list ───────────────────────────────────────────────────────────
|
||
function escHtml(s){return String(s ?? '').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||
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='/html/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/file',{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;
|
||
// 수식은 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){
|
||
return text
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^\|(.+)\|$/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>
|