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

575 lines
40 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 ── */
.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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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,'&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>