- mobToggle 가드 640→1024: 음악·슬라이드·코드·날씨·언어·치과·ESP32·아두이노 앱 (CSS max-width:1024px인데 JS guard가 >640으로 남아 폴드 내부화면에서 토글 불가) - #mob-tabbar z-index 400/490→600, 패널 bottom 0→52px, backdrop inset 0 0 52px 0 (backdrop이 탭바를 가려 열린 상태에서 다른 탭 전환 불가 문제 해결) - 모든 innerWidth<=640 자동트리거 →<=1024 통일 (9개 앱) - 탐정앱: 네트워크 분석 패널 (IP조회/Traceroute/Nmap/DNS/WHOIS/SSL/취약점/해시/웹스캔/배너/CVE) - voice-utils.js 분리 — 서브앱 공통 음성입력+TTS 유틸 - app.js: 컨텍스트 토큰 게이지 (세션 목록에 tok/max 표시), usage 이벤트 처리 - routes-music.ts: Demucs 보컬분리 + YouTube 다운로드 잡 관리 (TTL 자동정리) - server-v2.ts: 탐정 네트워크 API (traceroute/dns/whois/ssl/nmap/vuln/hash/webscan/cve) - session.ts: resolveNumCtx export, /api/agent/session contextTokenEstimate 노출 - config.json: 카카오 채널 설정, ITS 교통 API 키 vault 참조 - styles.css: app-mic-btn / app-tts-btn 공통 스타일 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
568 lines
44 KiB
HTML
568 lines
44 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="cherry_logo.png">
|
||
<link rel="stylesheet" href="styles.css">
|
||
<script>
|
||
try { var _t=localStorage.getItem('cherryclaw_theme')||'dark'; document.documentElement.setAttribute('data-theme',_t); } catch(e){}
|
||
</script>
|
||
<style>
|
||
html,body{height:100%;margin:0;padding:0;}
|
||
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
||
|
||
/* ── Header ── */
|
||
.ac-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
||
.ac-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
||
.ac-hdr-spacer{flex:1;}
|
||
.ac-hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;transition:.15s;font-family:inherit;}
|
||
.ac-hdr-btn:hover{border-color:#22c55e;color:#22c55e;}
|
||
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.25);color:#4ade80;font-weight:700;}
|
||
|
||
/* ── Layout ── */
|
||
.ac-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
||
|
||
/* ── Sidebar ── */
|
||
.ac-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.ac-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;}
|
||
.ac-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex-shrink:0;}
|
||
.ac-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.ac-qbtn:hover{border-color:#22c55e;color:#22c55e;background:rgba(34,197,94,.05);}
|
||
.ac-divider{height:1px;background:var(--line);margin:4px 0;}
|
||
.ac-file-hdr{display:flex;align-items:center;padding:0 10px 3px;flex-shrink:0;}
|
||
.ac-file-hdr span{flex:1;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
|
||
.ac-file-hdr button{background:none;border:none;padding:0;cursor:pointer;font-size:11px;color:var(--muted);transition:.12s;}
|
||
.ac-file-hdr button:hover{color:#22c55e;}
|
||
.ac-files{flex:1;overflow-y:auto;padding:0 4px;}
|
||
.ac-fi{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:5px;font-size:11px;color:var(--muted);transition:.12s;}
|
||
.ac-fi:hover{background:rgba(34,197,94,.07);color:var(--text);}
|
||
.ac-fi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
|
||
.ac-fi-btn{background:none;border:none;cursor:pointer;font-size:12px;padding:1px 3px;color:var(--muted);flex-shrink:0;border-radius:3px;transition:.12s;}
|
||
.ac-fi-btn:hover{color:#22c55e;}
|
||
.ac-upload{margin:6px;border:1px dashed var(--line);border-radius:6px;padding:5px;font-size:11px;color:var(--muted);cursor:pointer;text-align:center;background:none;font-family:inherit;transition:.15s;display:block;width:calc(100% - 12px);}
|
||
.ac-upload:hover{border-color:#22c55e;color:#22c55e;}
|
||
|
||
/* ── Center viewer ── */
|
||
.ac-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
|
||
.ac-center-hdr{display:flex;align-items:center;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;gap:8px;min-height:38px;}
|
||
.ac-center-title{font-size:12px;font-weight:700;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.ac-center-btn{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;flex-shrink:0;}
|
||
.ac-center-btn:hover{border-color:#22c55e;color:#22c55e;}
|
||
.ac-center-body{flex:1;overflow:hidden;display:flex;flex-direction:column;}
|
||
.ac-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:10px;padding:40px 20px;text-align:center;}
|
||
.ac-empty .icon{font-size:48px;opacity:.35;}
|
||
.ac-empty p{font-size:12px;line-height:1.6;max-width:240px;}
|
||
.ac-viewer{flex:1;border:none;display:none;width:100%;min-height:0;}
|
||
.ac-viewer.visible{display:block;}
|
||
|
||
/* ── Resize handle ── */
|
||
.ac-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
||
.ac-resize:hover,.ac-resize.dragging{background:#22c55e;}
|
||
|
||
/* ── Chat ── */
|
||
.ac-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
||
.ac-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
||
.ac-chat-label{font-size:10px;font-weight:700;color:#22c55e;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
||
.ac-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
|
||
.ac-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
||
.ac-chat-clear:hover{color:#ef4444;}
|
||
.ac-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
||
.ac-msg{display:flex;flex-direction:column;}
|
||
.ac-msg.user{align-items:flex-end;}
|
||
.ac-msg.assistant{align-items:flex-start;}
|
||
.ac-msg.system{align-items:center;}
|
||
.ac-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:92%;word-break:break-word;}
|
||
.ac-msg.user .ac-bubble{background:#14532d;color:#dcfce7;border-radius:10px 10px 3px 10px;}
|
||
.ac-msg.assistant .ac-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
||
.ac-msg.system .ac-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
||
.ac-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
||
.ac-bubble code{font-size:11px;}
|
||
.ac-bubble p{margin:3px 0;}
|
||
.ac-bubble h3{font-size:13px;margin:6px 0 3px;}
|
||
.ac-bubble ul,.ac-bubble ol{padding-left:16px;margin:3px 0;}
|
||
.ac-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
|
||
.ac-bubble th,.ac-bubble td{border:1px solid var(--line);padding:4px 7px;text-align:left;}
|
||
.ac-bubble th{background:var(--panel);font-weight:700;}
|
||
.ac-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
|
||
.ac-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
||
.ac-open-link{font-size:10px;color:#22c55e;cursor:pointer;text-decoration:underline;margin-top:2px;display:inline-block;}
|
||
.ac-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
||
.ac-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:acDot 1.2s ease-in-out infinite;}
|
||
.ac-dot:nth-child(2){animation-delay:.2s;}.ac-dot:nth-child(3){animation-delay:.4s;}
|
||
@keyframes acDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
||
.ac-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
||
.ac-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
|
||
.ac-textarea:focus{border-color:#22c55e;}
|
||
.ac-send{background:#14532d;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
||
.ac-send:hover:not(:disabled){background:#166534;}
|
||
.ac-send:disabled{opacity:.5;cursor:not-allowed;}
|
||
|
||
/* ── Drop overlay ── */
|
||
.ac-drop{position:fixed;inset:0;background:rgba(34,197,94,.12);border:3px dashed #22c55e;border-radius:12px;z-index:100;display:none;align-items:center;justify-content:center;font-size:18px;color:#22c55e;font-weight:700;pointer-events:none;}
|
||
.ac-drop.on{display:flex;}
|
||
|
||
/* ── Scrollbars ── */
|
||
.ac-files::-webkit-scrollbar,.ac-msgs::-webkit-scrollbar{width:3px;}
|
||
.ac-files::-webkit-scrollbar-thumb,.ac-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
||
|
||
/* ── Mobile ── */
|
||
@media(max-width:1024px){
|
||
.ac-resize{display:none;}
|
||
.ac-sb{
|
||
position:fixed;top:0;left:0;bottom:52px;z-index:500;
|
||
width:80vw;max-width:300px;
|
||
transform:translateX(-100%);transition:transform .25s ease;
|
||
box-shadow:4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.ac-sb.mob-open{transform:translateX(0);}
|
||
.ac-chat{
|
||
position:fixed;top:0;right:0;bottom:52px;z-index:500;
|
||
width:92vw;max-width:380px;
|
||
transform:translateX(100%);transition:transform .25s ease;
|
||
box-shadow:-4px 0 24px rgba(0,0,0,.4);
|
||
}
|
||
.ac-chat.mob-open{transform:translateX(0);}
|
||
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.5);z-index:499;}
|
||
#mob-backdrop.show{display:block;}
|
||
.ac-center{width:100%;}
|
||
.ac-center-body{padding-bottom:64px;}
|
||
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--line);height:52px;}
|
||
.ac-textarea,input,textarea{font-size:16px !important;}
|
||
}
|
||
@media(min-width:1025px){
|
||
#mob-tabbar{display:none !important;}
|
||
#mob-backdrop{display:none !important;}
|
||
}
|
||
</style>
|
||
<script src="voice-utils.js"></script>
|
||
</head>
|
||
<body>
|
||
<div class="ac-drop" id="drop-overlay">📊 Excel 파일을 드롭하세요</div>
|
||
|
||
<header class="ac-hdr">
|
||
<a href="/" class="ac-hdr-btn">← 홈</a>
|
||
<h1>📊 회계 어시스턴트</h1>
|
||
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
|
||
<div class="ac-hdr-spacer"></div>
|
||
<button class="ac-hdr-btn" onclick="toggleTheme()">🌙 테마</button>
|
||
</header>
|
||
|
||
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
|
||
|
||
<div id="mob-tabbar" style="display:none">
|
||
<button class="mob-tab" id="mobtab-files" onclick="mobToggle('files')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||
<span>파일</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M3 12L12 3l9 9"/><path d="M9 21V12h6v9"/></svg>
|
||
<span>홈</span>
|
||
</button>
|
||
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<span>AI</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="ac-layout">
|
||
|
||
<!-- ── Sidebar ── -->
|
||
<aside class="ac-sb">
|
||
<div class="ac-sb-sec">빠른 요청</div>
|
||
<div class="ac-quick">
|
||
<button class="ac-qbtn" onclick="sendQuick('손익계산서를 Excel로 만들어주세요. 매출, 매출원가, 판관비, 영업이익, 순이익 항목을 포함해주세요.')">📈 손익계산서 작성</button>
|
||
<button class="ac-qbtn" onclick="sendQuick('부가가치세 신고서를 Excel로 만들어주세요. 매출세액, 매입세액, 납부세액을 계산해주세요.')">🧾 부가세 계산</button>
|
||
<button class="ac-qbtn" onclick="sendQuick('재무상태표(대차대조표)를 Excel로 만들어주세요. 자산, 부채, 자본으로 구분해주세요.')">📋 재무상태표 작성</button>
|
||
<button class="ac-qbtn" onclick="sendQuick('급여명세서를 Excel로 만들어주세요. 기본급, 4대보험, 세금, 실수령액을 계산해주세요.')">💰 급여명세서 작성</button>
|
||
<button class="ac-qbtn" onclick="sendQuick('업로드한 재무제표를 분석해주세요. 수익성·안정성·성장성 지표를 계산하고 종합 의견을 주세요.')">🔍 재무제표 분석</button>
|
||
</div>
|
||
<div class="ac-divider"></div>
|
||
<div class="ac-file-hdr">
|
||
<span>Excel 파일</span>
|
||
<button onclick="loadExcelFiles()" title="새로고침">↺</button>
|
||
</div>
|
||
<div class="ac-files" id="excel-file-list">
|
||
<div style="font-size:11px;color:var(--muted);padding:4px 6px">로딩 중...</div>
|
||
</div>
|
||
<label class="ac-upload">
|
||
⬆ Excel 업로드
|
||
<input type="file" accept=".xlsx,.xls,.csv" style="display:none" onchange="uploadExcelFile(this.files[0]);this.value=''">
|
||
</label>
|
||
</aside>
|
||
|
||
<!-- ── Center viewer ── -->
|
||
<section class="ac-center" id="ac-center">
|
||
<div class="ac-center-hdr">
|
||
<span class="ac-center-title" id="viewer-title">스프레드시트 뷰어</span>
|
||
<button class="ac-center-btn" id="viewer-new-btn" onclick="newSheet()" title="새 시트">+ 새 시트</button>
|
||
<button class="ac-center-btn" id="viewer-close-btn" onclick="closeViewer()" style="display:none">✕ 닫기</button>
|
||
</div>
|
||
<div class="ac-center-body" id="center-body">
|
||
<div class="ac-empty" id="viewer-empty">
|
||
<div class="icon">📊</div>
|
||
<p>사이드바에서 Excel 파일을 열거나<br>AI에게 표·분석 작성을 요청하면<br>여기에 스프레드시트가 열립니다.</p>
|
||
</div>
|
||
<iframe class="ac-viewer" id="xlsx-viewer" src="" allow="clipboard-read; clipboard-write"></iframe>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ── Resize handle ── -->
|
||
<div class="ac-resize" id="resize-handle"></div>
|
||
|
||
<!-- ── Chat ── -->
|
||
<div class="ac-chat" id="chat-panel">
|
||
<div class="ac-chat-hdr">
|
||
<span class="ac-chat-label">📊 AI</span>
|
||
<select class="ac-model-sel" id="model-sel" onchange="saveModel()">
|
||
<option value="">기본 모델</option>
|
||
</select>
|
||
<button class="ac-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||
</div>
|
||
<div class="ac-msgs" id="chat-msgs"></div>
|
||
<div class="ac-input-bar">
|
||
<textarea class="ac-textarea" id="chat-input" rows="1" placeholder="세무·회계·재무에 대해 질문하세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||
<button class="app-mic-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력">🎤</button>
|
||
<button class="ac-send" id="send-btn" onclick="sendMessage()">전송</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||
const TOKEN_KEY = 'smallclaw_token';
|
||
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
||
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
||
|
||
async function checkAuth(){
|
||
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
|
||
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
||
}
|
||
|
||
// ── Mobile overlay ────────────────────────────────────────────────────────────
|
||
function mobCloseAll(){
|
||
document.querySelector('.ac-sb').classList.remove('mob-open');
|
||
document.getElementById('chat-panel').classList.remove('mob-open');
|
||
document.getElementById('mob-backdrop').classList.remove('show');
|
||
['files','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
|
||
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#22c55e);border-top:2px solid var(--brand,#22c55e)';
|
||
}
|
||
function mobToggle(which){
|
||
const sb=document.querySelector('.ac-sb');
|
||
const cp=document.getElementById('chat-panel');
|
||
const bd=document.getElementById('mob-backdrop');
|
||
if(which==='home'){mobCloseAll();return;}
|
||
const sbOpen=sb.classList.contains('mob-open');
|
||
const cpOpen=cp.classList.contains('mob-open');
|
||
mobCloseAll();
|
||
if(which==='files'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-files').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
|
||
}
|
||
|
||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
|
||
|
||
// ── Resize ────────────────────────────────────────────────────────────────────
|
||
(function(){
|
||
const CHAT_W='ac_chat_width';
|
||
const handle=document.getElementById('resize-handle');
|
||
const panel=document.getElementById('chat-panel');
|
||
let dragging=false,startX=0,startW=0;
|
||
const saved=parseInt(localStorage.getItem(CHAT_W)||'340',10);
|
||
panel.style.width=saved+'px';
|
||
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
|
||
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
|
||
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
|
||
})();
|
||
|
||
// ── Model ─────────────────────────────────────────────────────────────────────
|
||
const MODEL_KEY='ac_model';
|
||
let selectedModel=localStorage.getItem(MODEL_KEY)||'';
|
||
|
||
async function loadModels(){
|
||
const sel=document.getElementById('model-sel');
|
||
try{
|
||
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
|
||
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
|
||
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
|
||
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
|
||
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
|
||
for(const m of models){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);}
|
||
if(!selectedModel)sel.value='';
|
||
}catch{}
|
||
}
|
||
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
|
||
|
||
// ── Skill ─────────────────────────────────────────────────────────────────────
|
||
const APP_KEY='ac';
|
||
let skillContext='';
|
||
|
||
let _sessionId=null;
|
||
function getSessionId(){return _sessionId||'ac_main';}
|
||
async function initSessionId(){
|
||
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
|
||
_sessionId='ac_'+Math.random().toString(36).slice(2,12);
|
||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||
}
|
||
|
||
async function loadHistory(){
|
||
const sid=getSessionId();
|
||
if(!sid||sid.endsWith('_main'))return;
|
||
try{
|
||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||
if(!r.ok)return;
|
||
const d=await r.json();
|
||
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
|
||
if(!msgs.length)return;
|
||
const recent=msgs.slice(-30);
|
||
const el=document.getElementById('chat-msgs');
|
||
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
|
||
for(const m of recent){
|
||
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
||
else addMsg('assistant',renderMd(m.content||''));
|
||
}
|
||
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
|
||
el.scrollTop=el.scrollHeight;
|
||
}catch{}
|
||
}
|
||
|
||
|
||
|
||
const SKILL_CONTENT="---\nname: Accountant\ndescription: \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34\ubd84\uc11d\u00b7\uacb0\uc0b0\u00b7\uc6d0\uac00\uad00\ub9ac\u00b7\ud22c\uc790\ud3c9\uac00\ub97c \uc544\uc6b0\ub974\ub294 \uacf5\uc778\ud68c\uacc4\uc0ac \uc5b4\uc2dc\uc2a4\ud134\ud2b8. K-IFRS, \uc138\ubc95, \ubc95\uc778\u00b7\uac1c\uc778 \uc138\ubb34 \uc804\ubc18 \uc9c0\uc6d0.\nemoji: \"\ud83d\udcca\"\nversion: 1.0.0\ntriggers: [\uc138\uae08, \uc138\ubb34, \ud68c\uacc4, \uc7ac\ubb34, \uacb0\uc0b0, \ubd80\uac00\uc138, \uc18c\ub4dd\uc138, \ubc95\uc778\uc138, \ube44\uc6a9, \uc190\uc775, \uc790\uc0b0, \ubd80\ucc44, \uac10\uac00\uc0c1\uac01, \ubd84\uac1c, \uc7a5\ubd80, \uc6d0\uac00, \uc808\uc138, \ud658\uae09, \uc2e0\uace0, \ub0a9\ubd80, \uacf5\uc81c, \ub9e4\ucd9c, \uc601\uc218\uc99d, \uc138\uae08\uacc4\uc0b0\uc11c]\n---\n\n\ub2f9\uc2e0\uc740 \uacf5\uc778\ud68c\uacc4\uc0ac(CPA) \uc218\uc900\uc758 \uc138\ubb34\u00b7\ud68c\uacc4\u00b7\uc7ac\ubb34 \uc804\ubb38 \uc5b4\uc2dc\uc2a4\ud134\ud2b8\uc785\ub2c8\ub2e4. \uc815\ud655\ud558\uace0 \uc2e4\uc6a9\uc801\uc778 \ub2f5\ubcc0\uc744 \uc81c\uacf5\ud558\uba70, \ubcf5\uc7a1\ud55c \uac1c\ub150\uc740 \uc27d\uac8c \ud480\uc5b4 \uc124\uba85\ud558\uc138\uc694. \ud68c\uacc4\u00b7\uc138\ubb34 \uc6a9\uc5b4\ub294 \ud55c\uad6d\uc5b4 \uba3c\uc800, \uc601\uc5b4\ub97c \uad04\ud638\uc5d0 \ubcd1\uae30\ud558\uc138\uc694. (\uc608: \uac10\uac00\uc0c1\uac01(depreciation))\n\n---\n\n### \uc0ac\uc6a9 \uac00\ub2a5\ud55c \ub3c4\uad6c\n\n| \ub3c4\uad6c | \ud65c\uc6a9 \uc0c1\ud669 |\n|---|---|\n| `web_search` | \uc138\ubc95 \uac1c\uc815\uc0ac\ud56d\u00b7\uad6d\uc138\uccad \uace0\uc2dc\u00b7\ud68c\uacc4\uae30\uc900 \ubcc0\uacbd\u00b7\ud310\ub840\u00b7\uc138\uc728\ud45c \ucd5c\uc2e0 \uc815\ubcf4 \uac80\uc0c9 |\n| `pdf_read` | \uc7ac\ubb34\uc81c\ud45c\u00b7\uc138\uae08\uacc4\uc0b0\uc11c\u00b7\uacc4\uc57d\uc11c\u00b7\uac10\uc0ac\ubcf4\uace0\uc11c PDF \ubd84\uc11d |\n| `excel_read` | Excel \uc7ac\ubb34\uc81c\ud45c\u00b7\uc6d0\uc7a5\u00b7\uc138\uae08\uacc4\uc0b0\uc11c \ub370\uc774\ud130 \uc77d\uae30 (\uc2dc\ud2b8 \uc9c0\uc815, \ubc94\uc704 \uc9c0\uc815 \uac00\ub2a5) |\n| `excel_write` | \uc138\uc561 \uacc4\uc0b0 \uacb0\uacfc\u00b7\uc7ac\ubb34\ubd84\uc11d \ubcf4\uace0\uc11c\ub97c Excel \ud30c\uc77c\ub85c \uc800\uc7a5 |\n| `python_eval` | \uc138\uc561 \uacc4\uc0b0, \uc7ac\ubb34\ube44\uc728 \uc0b0\ucd9c, \uc190\uc775\ubd84\uc11d, \ud604\uae08\ud750\ub984 \ucd94\uc815, \ubcf5\ub9ac \uacc4\uc0b0 |\n\n### \uc6b0\uc120 \ucc38\uc870 \uc0ac\uc774\ud2b8\n\n`web_search` \ud638\ucd9c \uc2dc \uc544\ub798 **site: \uc5f0\uc0b0\uc790\ub97c query\uc5d0 \ud3ec\ud568**\ud574 \uacf5\uc2e0\ub825 \uc788\ub294 \uc138\ubb34\u00b7\ud68c\uacc4 \uc815\ubcf4\ub97c \uc6b0\uc120 \uac80\uc0c9\ud558\uc138\uc694.\n\n| \uc8fc\uc81c | site: \uc5f0\uc0b0\uc790 | \uc124\uba85 |\n|---|---|---|\n| \uc138\ubc95 \uc6d0\ubb38\u00b7\uc720\uad8c\ud574\uc11d | `site:taxlaw.nts.go.kr` | \uad6d\uc138\ubc95\ub839\uc815\ubcf4\uc2dc\uc2a4\ud15c (\ubc95\ub839\u00b7\uc608\uaddc\u00b7\ud310\ub840) |\n| \uad6d\uc138\uccad \uacf5\uc2dd \uc548\ub0b4 | `site:nts.go.kr` | \uc138\uc728\ud45c\u00b7\uc2e0\uace0 \ubc29\ubc95\u00b7\uac01\uc885 \uace0\uc2dc |\n| \uae30\uc5c5 \uacf5\uc2dc (\uc7ac\ubb34\uc81c\ud45c) | `site:dart.fss.or.kr` | \uae08\uc735\uac10\ub3c5\uc6d0 DART \uc804\uc790\uacf5\uc2dc |\n| K-IFRS \ud68c\uacc4\uae30\uc900 | `site:kasb.or.kr` | \ud55c\uad6d\ud68c\uacc4\uae30\uc900\uc6d0 \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uae30\ud68d\uc7ac\uc815\ubd80 \uc815\ucc45 | `site:moef.go.kr` | \uc138\uc81c \uac1c\ud3b8\uc548\u00b7\uc608\uc0b0\u00b7\uc7ac\uc815 \uc815\ucc45 |\n| \uad6d\uc81c IFRS \uae30\uc900 | `site:ifrs.org` | IFRS Foundation \uae30\uc900\uc11c \uc6d0\ubb38 |\n| \uad00\uc138\u00b7\ubb34\uc5ed | `site:customs.go.kr` | \uad00\uc138\uccad (\uc218\ucd9c\uc785 \uc138\uc728\u00b7\ud488\ubaa9\ubd84\ub958) |\n\n\uc608\uc2dc: 2024\ub144 \ubc95\uc778\uc138\uc728 \ubcc0\uacbd \u2192 `web_search(\"site:nts.go.kr \ubc95\uc778\uc138\uc728 2024 \uac1c\uc815\")`\n\n---\n\n### \uc138\ubb34 \uc0c1\ub2f4\n\n#### \uac1c\uc778 \uc138\uae08\n- **\uc885\ud569\uc18c\ub4dd\uc138**: \uc18c\ub4dd \uc885\ub958\ubcc4 \ubd84\ub958(\uadfc\ub85c\u00b7\uc0ac\uc5c5\u00b7\uc774\uc790\u00b7\ubc30\ub2f9\u00b7\uc5f0\uae08\u00b7\uae30\ud0c0), \uacf5\uc81c \ud56d\ubaa9 \ucd5c\uc801\ud654\n- **\uc591\ub3c4\uc18c\ub4dd\uc138**: \ubd80\ub3d9\uc0b0\u00b7\uc8fc\uc2dd\u00b7\ube44\uc0c1\uc7a5\uc8fc\uc2dd \uc591\ub3c4\ucc28\uc775 \uacc4\uc0b0, \ube44\uacfc\uc138\u00b7\uac10\uba74 \uc694\uac74\n- **\uc0c1\uc18d\u00b7\uc99d\uc5ec\uc138**: \uacfc\uc138\uac00\uc561 \uacc4\uc0b0, \uacf5\uc81c \ud55c\ub3c4, \uc2e0\uace0 \uae30\ud55c(3\uac1c\uc6d4/6\uac1c\uc6d4)\n- **\ubd80\uac00\uac00\uce58\uc138**: \uc77c\ubc18\u00b7\uac04\uc774\uacfc\uc138\uc790 \uad6c\ubd84, \ub9e4\uc785\uc138\uc561 \uacf5\uc81c, \uc2e0\uace0 \uc8fc\uae30\n- **\uae08\uc735\uc18c\ub4dd \uc885\ud569\uacfc\uc138**: 2\ucc9c\ub9cc \uc6d0 \ucd08\uacfc \uc2dc \ud569\uc0b0 \uae30\uc900, \uc6d0\ucc9c\uc9d5\uc218 \uc138\uc728\n\n#### \ubc95\uc778 \uc138\uae08\n- **\ubc95\uc778\uc138**: \uacfc\uc138\ud45c\uc900 \uacc4\uc0b0, \uc138\uc728 \uad6c\uac04(2\uc5b5 \uc774\ud558 9%\u00b72\uc5b5~200\uc5b5 19%\u00b7200\uc5b5~3\ucc9c\uc5b5 21%\u00b7\ucd08\uacfc 24%), \uc138\uc561\uacf5\uc81c\u00b7\uac10\uba74\n- **\uc6d0\ucc9c\uc9d5\uc218**: \uc778\uac74\ube44\u00b7\uc6a9\uc5ed\ube44\u00b7\uc774\uc790\u00b7\ubc30\ub2f9 \uc6d0\ucc9c\uc138 \uc138\uc728\ud45c\n- **\uc774\uc804\uac00\uaca9**: \ud2b9\uc218\uad00\uacc4\uc790 \uac70\ub798 \uc815\uc0c1\uac00\uaca9 \uc0b0\uc815 \ubc29\ubc95\n- **\uc138\ubb34\uc870\uc0ac**: \ub300\ube44 \uc11c\ub958, \uc18c\uba85 \ubc29\ubc95, \ubd88\ubcf5 \uc808\ucc28(\uc774\uc758\uc2e0\uccad \u2192 \uc2ec\ud310\uccad\uad6c \u2192 \ud589\uc815\uc18c\uc1a1)\n\n\uc138\uae08 \uacc4\uc0b0 \uc2dc `python_eval`\ub85c \uc815\ud655\ud55c \uc0b0\uc2dd\uc744 \uc81c\uc2dc\ud558\uc138\uc694:\n```python\n# \uc608: \uc591\ub3c4\uc18c\ub4dd\uc138 \uacc4\uc0b0\n\uc591\ub3c4\uac00\uc561 = ...\n\ucde8\ub4dd\uac00\uc561 = ...\n\ud544\uc694\uacbd\ube44 = ...\n\uc591\ub3c4\ucc28\uc775 = \uc591\ub3c4\uac00\uc561 - \ucde8\ub4dd\uac00\uc561 - \ud544\uc694\uacbd\ube44\n\uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c = ...\n\uacfc\uc138\ud45c\uc900 = \uc591\ub3c4\ucc28\uc775 - \uc7a5\uae30\ubcf4\uc720\ud2b9\ubcc4\uacf5\uc81c - \uae30\ubcf8\uacf5\uc81c(2_500_000)\n\uc138\uc728 = ...\n\uc0b0\ucd9c\uc138\uc561 = \uacfc\uc138\ud45c\uc900 * \uc138\uc728\n```\n\n---\n\n### \uc7ac\ubb34\uc81c\ud45c \ubd84\uc11d\n\n\uc7ac\ubb34\uc81c\ud45c(\uc190\uc775\uacc4\uc0b0\uc11c\u00b7\uc7ac\ubb34\uc0c1\ud0dc\ud45c\u00b7\ud604\uae08\ud750\ub984\ud45c)\uac00 \uc81c\uacf5\ub418\uba74 \ub2e4\uc74c \uc21c\uc11c\ub85c \ubd84\uc11d\ud558\uc138\uc694:\n\n1. **\uc218\uc775\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\ucd1d\uc774\uc775\ub960(Gross Margin) = \ub9e4\ucd9c\ucd1d\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc601\uc5c5\uc774\uc775\ub960(Operating Margin) = \uc601\uc5c5\uc774\uc775 / \ub9e4\ucd9c\uc561\n - \uc21c\uc774\uc775\ub960(Net Margin) = \ub2f9\uae30\uc21c\uc774\uc775 / \ub9e4\ucd9c\uc561\n - ROE = \ub2f9\uae30\uc21c\uc774\uc775 / \uc790\uae30\uc790\ubcf8, ROA = \ub2f9\uae30\uc21c\uc774\uc775 / \ucd1d\uc790\uc0b0\n\n2. **\uc548\uc815\uc131 \ubd84\uc11d**\n - \uc720\ub3d9\ube44\uc728 = \uc720\ub3d9\uc790\uc0b0 / \uc720\ub3d9\ubd80\ucc44 (200% \uc774\uc0c1 \uc591\ud638)\n - \ubd80\ucc44\ube44\uc728 = \ubd80\ucc44 / \uc790\uae30\uc790\ubcf8 (100% \uc774\ud558 \uc548\uc815)\n - \uc774\uc790\ubcf4\uc0c1\ubc30\uc728 = \uc601\uc5c5\uc774\uc775 / \uc774\uc790\ube44\uc6a9 (3\ubc30 \uc774\uc0c1 \uad8c\uace0)\n\n3. **\uc131\uc7a5\uc131\u00b7\ud6a8\uc728\uc131 \ubd84\uc11d**\n - \ub9e4\ucd9c\uc131\uc7a5\ub960, \uc7ac\uace0\uc790\uc0b0\ud68c\uc804\uc728, \ub9e4\ucd9c\ucc44\uad8c\ud68c\uc804\uc728\n\n4. **\ud604\uae08\ud750\ub984 \ubd84\uc11d**\n - \uc601\uc5c5/\ud22c\uc790/\uc7ac\ubb34 \ud604\uae08\ud750\ub984 \uad6c\uc870, FCF(\uc789\uc5ec\ud604\uae08\ud750\ub984) \uacc4\uc0b0\n\n5. **\uc885\ud569 \uc758\uacac** \u2014 \uc7ac\ubb34 \uac74\uc804\uc131 \ud3c9\uac00 + \uc8fc\uc758 \ud56d\ubaa9 + \uac1c\uc120 \uc81c\uc548\n\n---\n\n### \ud68c\uacc4\ucc98\ub9ac (\ubd84\uac1c\u00b7\uae30\uc7a5)\n\n\ud68c\uacc4\ucc98\ub9ac \uc9c8\ubb38 \uc2dc:\n- **\ubd84\uac1c \ud615\uc2dd**: `(\ucc28) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561 / (\ub300) \uacc4\uc815\uacfc\ubaa9 \uae08\uc561`\n- **\uc801\uc6a9 \uae30\uc900**: K-IFRS(\uc0c1\uc7a5\u00b7\ub300\ud615) \ub610\ub294 \uc77c\ubc18\uae30\uc5c5\ud68c\uacc4\uae30\uc900(K-GAAP, \uc911\uc18c\uae30\uc5c5)\n- **\uc8fc\uc694 \ud56d\ubaa9**: \uac10\uac00\uc0c1\uac01, \ucda9\ub2f9\ubd80\ucc44, \ub9ac\uc2a4(IFRS 16), \uc218\uc775\uc778\uc2dd(IFRS 15), \uae08\uc735\uc0c1\ud488(IFRS 9)\n\n\uc608\uc2dc:\n```\n\ube44\ud488 \ucde8\ub4dd (\ubd80\uac00\uc138 \ud3ec\ud568)\n(\ucc28) \ube44\ud488 1,000,000\n \ubd80\uac00\uc138\ub300\uae09\uae08 100,000\n(\ub300) \ubcf4\ud1b5\uc608\uae08 1,100,000\n```\n\n---\n\n### \uc6d0\uac00 \uad00\ub9ac / \uad00\ub9ac\ud68c\uacc4\n\n- **\uc6d0\uac00 \ubd84\ub958**: \ubcc0\ub3d9\ube44\u00b7\uace0\uc815\ube44, \uc9c1\uc811\ube44\u00b7\uac04\uc811\ube44, \uc81c\uc870\uc6d0\uac00\u00b7\ud310\uad00\ube44\n- **\uc190\uc775\ubd84\uae30\uc810(BEP)**: BEP = \uace0\uc815\ube44 / (\ub2e8\uc704 \ud310\ub9e4\uac00 - \ub2e8\uc704 \ubcc0\ub3d9\ube44)\n- **CVP \ubd84\uc11d**: \ubaa9\ud45c\uc774\uc775 \ub2ec\uc131 \ud310\ub9e4\ub7c9 \uacc4\uc0b0\n- **\uc608\uc0b0 \ub300\ube44 \uc2e4\uc801(Budget vs. Actual)**: \ucc28\uc774 \uc6d0\uc778 \ubd84\uc11d(\uac00\uaca9\ucc28\uc774\u00b7\uc218\ub7c9\ucc28\uc774)\n- **ABC \uc6d0\uac00\uacc4\uc0b0**: \ud65c\ub3d9 \uae30\ubc18 \uc6d0\uac00 \ubc30\ubd80\n\n---\n\n### \ud22c\uc790\u00b7\uac00\uce58\ud3c9\uac00\n\n- **DCF(\ud604\uae08\ud750\ub984\ud560\uc778\ubc95)**: WACC \uc0b0\ucd9c, \uc794\uc5ec\uac00\uce58(Terminal Value) \uacc4\uc0b0\n- **\uba40\ud2f0\ud50c \ubc38\ub958\uc5d0\uc774\uc158**: PER, PBR, EV/EBITDA, PSR\n- **IRR\u00b7NPV**: \ud22c\uc790 \ud0c0\ub2f9\uc131 \ud310\ub2e8\n- **\ubd80\ub3d9\uc0b0 \ud22c\uc790 \uc218\uc775\ub960**: \uc784\ub300\uc218\uc775\ub960, \uc790\ubcf8\ud658\uc6d0\uc728(Cap Rate)\n\n---\n\n### \ucc3d\uc5c5\u00b7\uc0ac\uc5c5 \ud68c\uacc4 \uc2e4\ubb34\n\n- **\uc0ac\uc5c5\uc790 \uc720\ud615 \uc120\ud0dd**: \uac1c\uc778\uc0ac\uc5c5\uc790 vs \ubc95\uc778 \uc124\ub9bd (\uc138\uae08\u00b7\ucc45\uc784\u00b7\ube44\uc6a9 \ube44\uad50)\n- **\uac04\uc774\uacfc\uc138 vs \uc77c\ubc18\uacfc\uc138**: \uc5f0 \ub9e4\ucd9c 8\ucc9c\ub9cc \uc6d0 \uae30\uc900, \uc5c5\uc885\ubcc4 \ubd80\uac00\uc728\n- **4\ub300 \ubcf4\ud5d8**: \uc9c1\uc6d0 \uace0\uc6a9 \uc2dc \uc0ac\uc5c5\uc8fc \ubd80\ub2f4 \ubcf4\ud5d8\ub8cc \uacc4\uc0b0\n- **\uac00\uc0b0\uc138 \uc8fc\uc758**: \ubb34\uc2e0\uace0\u00b7\uacfc\uc18c\uc2e0\uace0\u00b7\ub0a9\ubd80\ubd88\uc131\uc2e4 \uac00\uc0b0\uc138 \uc138\uc728\n- **\uc804\uc790\uc138\uae08\uacc4\uc0b0\uc11c**: \ubc1c\ud589 \uc758\ubb34, \uc9c0\uc5f0\ubc1c\ud589 \uac00\uc0b0\uc138, \uc218\uc815\ubc1c\ud589 \ubc29\ubc95\n\n---\n\n### \uc751\ub2f5 \ud1a4 \uac00\uc774\ub4dc\n\n- **\uc815\ud655\u00b7\uc2e0\ub8b0\uac10**: \uc218\uce58\uc640 \uc138\uc728\uc740 \uc815\ud655\ud558\uac8c, \ubd88\ud655\uc2e4\ud55c \uacbd\uc6b0 \"\ucd5c\uc2e0 \uc138\ubc95 \ud655\uc778 \uad8c\uc7a5\" \uba85\uc2dc\n- **\uc2e4\uc6a9\uc801**: \uc774\ub860\ubcf4\ub2e4 \"\uc2e4\uc81c\ub85c \uc5bc\ub9c8\" \"\uc5b4\ub514\uc5d0 \uc2e0\uace0\" \"\uc5b8\uc81c\uae4c\uc9c0\" \uc704\uc8fc\n- **\ub2e8\uacc4\ubcc4 \uc548\ub0b4**: \ubcf5\uc7a1\ud55c \uc138\ubb34 \uc774\uc288\ub294 \u2460 \u2461 \u2462 \uc21c\uc11c\ub85c \uc815\ub9ac\n- **\uacc4\uc0b0\uc740 \ucf54\ub4dc\ub85c**: \uc138\uc561 \uacc4\uc0b0\uc774 \ud3ec\ud568\ub41c \uacbd\uc6b0 `python_eval`\ub85c \ud22c\uba85\ud558\uac8c \uc0b0\uc2dd \uacf5\uac1c\n\n\uc774 \uc11c\ube44\uc2a4\ub294 \uc815\ubcf4 \uc81c\uacf5 \ubaa9\uc801\uc774\uba70 \uacf5\uc2dd \uc138\ubb34\u00b7\ubc95\ub960 \uc790\ubb38\uc744 \ub300\uccb4\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. \uc911\uc694\ud55c \uacb0\uc815 \uc804 \ub2f4\ub2f9 \uc138\ubb34\uc0ac\u00b7\ud68c\uacc4\uc0ac\uc5d0\uac8c \ud655\uc778\ud558\uc138\uc694.\n";
|
||
function loadSkill(){
|
||
skillContext=SKILL_CONTENT;
|
||
document.getElementById('skill-badge').textContent='📊 회계 전문가 활성';
|
||
}
|
||
|
||
// ── Excel file list ───────────────────────────────────────────────────────────
|
||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function escAttr(s){return String(s||'').replace(/'/g,"\\'");}
|
||
|
||
async function loadExcelFiles(){
|
||
const el=document.getElementById('excel-file-list');
|
||
try{
|
||
const r=await fetch('/api/excel/list',{headers:authH()});
|
||
const data=await r.json();
|
||
if(!data.files||!data.files.length){el.innerHTML='<div style="font-size:11px;color:var(--muted);padding:4px 6px">파일 없음</div>';return;}
|
||
el.innerHTML=data.files.map(f=>`
|
||
<div class="ac-fi">
|
||
<span class="ac-fi-name" onclick="openViewer('${escAttr(f.apiPath)}','${escAttr(f.name)}')" title="${escHtml(f.relPath)}">${escHtml(f.name)}</span>
|
||
<button class="ac-fi-btn" onclick="openViewer('${escAttr(f.apiPath)}','${escAttr(f.name)}')" title="열기">📂</button>
|
||
</div>`).join('');
|
||
}catch{el.innerHTML='<div style="font-size:11px;color:var(--err);padding:4px 6px">로드 실패</div>';}
|
||
}
|
||
|
||
// ── Viewer ────────────────────────────────────────────────────────────────────
|
||
function openViewer(apiPath, name){
|
||
const iframe=document.getElementById('xlsx-viewer');
|
||
const empty=document.getElementById('viewer-empty');
|
||
const closeBtn=document.getElementById('viewer-close-btn');
|
||
const title=document.getElementById('viewer-title');
|
||
empty.style.display='none';
|
||
iframe.src='/xlsx-viewer.html?file='+encodeURIComponent(apiPath)+'&save='+encodeURIComponent(apiPath)+'&title='+encodeURIComponent(name);
|
||
iframe.classList.add('visible');
|
||
title.textContent=name;
|
||
closeBtn.style.display='';
|
||
}
|
||
|
||
function closeViewer(){
|
||
const iframe=document.getElementById('xlsx-viewer');
|
||
iframe.src='';iframe.classList.remove('visible');
|
||
document.getElementById('viewer-empty').style.display='';
|
||
document.getElementById('viewer-title').textContent='스프레드시트 뷰어';
|
||
document.getElementById('viewer-close-btn').style.display='none';
|
||
}
|
||
|
||
function newSheet(){
|
||
openViewer('/api/files/__new__.xlsx','새 시트.xlsx');
|
||
}
|
||
|
||
async function uploadExcelFile(file){
|
||
if(!file)return;
|
||
addMsg('system','📊 '+escHtml(file.name)+' 업로드 중...');
|
||
try{
|
||
const form=new FormData();form.append('file',file);
|
||
const r=await fetch('/api/upload',{method:'POST',headers:authH(),body:form});
|
||
if(!r.ok)throw new Error('업로드 실패 '+r.status);
|
||
const data=await r.json();
|
||
const apiPath=data.url||data.path||'/api/files/'+file.name;
|
||
openViewer(apiPath,file.name);
|
||
addMsg('system','✅ '+escHtml(file.name)+' 열기 완료');
|
||
setTimeout(loadExcelFiles,1000);
|
||
}catch(e){
|
||
// Fallback: create object URL and open locally
|
||
const url=URL.createObjectURL(file);
|
||
openViewer(url,file.name);
|
||
addMsg('system','📂 '+escHtml(file.name)+' (로컬 열기)');
|
||
}
|
||
}
|
||
|
||
// Detect xlsx file path in AI response and auto-open
|
||
function detectAndOpenXlsx(text){
|
||
// Look for patterns like "saved: path.xlsx", "/path/to/file.xlsx", "파일: xxx.xlsx"
|
||
const patterns=[
|
||
/Excel file saved[^|]*\|\s*Sheet[^|]*\|\s*Rows[^|]*/, // tool result
|
||
/([^\s"'`]+\.xlsx)/gi,
|
||
];
|
||
const m=text.match(/([^\s"'`((]+\.xlsx)/i);
|
||
if(!m)return;
|
||
const raw=m[1];
|
||
// Build API path: if it looks like a workspace-relative path, try /api/files/
|
||
let apiPath=null;
|
||
if(raw.startsWith('/api/files/'))apiPath=raw;
|
||
else if(!raw.startsWith('/'))apiPath='/api/files/'+raw;
|
||
else apiPath='/api/files'+raw;
|
||
const name=raw.split('/').pop()||raw;
|
||
// Small delay to let server finish writing
|
||
setTimeout(()=>{
|
||
fetch(apiPath,{headers:authH(),method:'HEAD'}).then(r=>{
|
||
if(r.ok){openViewer(apiPath,name);loadExcelFiles();}
|
||
}).catch(()=>{});
|
||
},800);
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
function addMsg(role,html){
|
||
const msgs=document.getElementById('chat-msgs');
|
||
const div=document.createElement('div');div.className='ac-msg '+role;
|
||
const b=document.createElement('div');b.className='ac-bubble';b.innerHTML=html;
|
||
div.appendChild(b);
|
||
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
|
||
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
||
return b;
|
||
}
|
||
|
||
function renderMd(text){
|
||
return text
|
||
.replace(/&/g,'&').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>
|