Files
homeclaw/web-ui/accountant-app.html
T
kimandClaude Sonnet 4.6 9ad63ec8b1 v3.0.8: 스킬앱 독립화 + 치과에이전트 + 음악/MCP/보이스 라우트 + DB 워크플로 개선
- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent
  각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거
- 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice
- skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입
- 세션 삭제 API: DELETE /api/admin/sessions/:username/:id
- Telegram defaultUsername: 어드민 계정 자동 추론
- Ollama /api/ollama/models에 vision 지원 여부 플래그 추가
- DB 워크플로 개선: workflow_images.py 대규모 리팩터링
- 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄)
- 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 17:07:58 +09:00

460 lines
39 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;}
</style>
</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 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="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;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
// ── 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 SID_KEY='ac_app_session';
let skillContext='';
function getSessionId(){try{let s=sessionStorage.getItem(SID_KEY);if(!s){s='ac_'+Math.random().toString(36).slice(2,12);sessionStorage.setItem(SID_KEY,s);}return s;}catch{return 'ac_main';}}
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);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);
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){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='전송';}
}
function clearChat(){
if(!confirm('대화를 지울까요?'))return;
document.getElementById('chat-msgs').innerHTML='';
try{sessionStorage.removeItem(SID_KEY);}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 Promise.all([loadSkill(),loadModels(),loadExcelFiles()]);
addMsg('system','📊 회계 어시스턴트입니다. 세무·회계·재무분석을 도와드립니다.');
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>