v3.2.2: 폴더블 폰 모바일 패널 토글 전면 수정 + 탐정앱 네트워크 분석 + 음성 유틸

- mobToggle 가드 640→1024: 음악·슬라이드·코드·날씨·언어·치과·ESP32·아두이노 앱
  (CSS max-width:1024px인데 JS guard가 >640으로 남아 폴드 내부화면에서 토글 불가)
- #mob-tabbar z-index 400/490→600, 패널 bottom 0→52px, backdrop inset 0 0 52px 0
  (backdrop이 탭바를 가려 열린 상태에서 다른 탭 전환 불가 문제 해결)
- 모든 innerWidth<=640 자동트리거 →<=1024 통일 (9개 앱)
- 탐정앱: 네트워크 분석 패널 (IP조회/Traceroute/Nmap/DNS/WHOIS/SSL/취약점/해시/웹스캔/배너/CVE)
- voice-utils.js 분리 — 서브앱 공통 음성입력+TTS 유틸
- app.js: 컨텍스트 토큰 게이지 (세션 목록에 tok/max 표시), usage 이벤트 처리
- routes-music.ts: Demucs 보컬분리 + YouTube 다운로드 잡 관리 (TTL 자동정리)
- server-v2.ts: 탐정 네트워크 API (traceroute/dns/whois/ssl/nmap/vuln/hash/webscan/cve)
- session.ts: resolveNumCtx export, /api/agent/session contextTokenEstimate 노출
- config.json: 카카오 채널 설정, ITS 교통 API 키 vault 참조
- styles.css: app-mic-btn / app-tts-btn 공통 스타일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-27 19:16:59 +09:00
co-authored by Claude Sonnet 4.6
parent 5a1ecfe36d
commit 75a36cdc7d
22 changed files with 1710 additions and 91 deletions
+12 -7
View File
@@ -111,24 +111,24 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
@media(max-width:1024px){
.ac-resize{display:none;}
.ac-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
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:0;z-index:500;
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;background:rgba(0,0,0,.5);z-index:499;}
#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:400;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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){
@@ -136,6 +136,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
#mob-backdrop{display:none !important;}
}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
<div class="ac-drop" id="drop-overlay">📊 Excel 파일을 드롭하세요</div>
@@ -222,6 +223,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<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>
@@ -427,7 +429,9 @@ 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;
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;
}
@@ -464,13 +468,13 @@ async function sendMessage(){
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=640) mobToggle('chat');
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
document.getElementById('chat-input').value=msg;
sendMessage();
}
@@ -555,6 +559,7 @@ document.addEventListener('drop',e=>{
await loadHistory();
await Promise.all([loadSkill(),loadModels(),loadExcelFiles()]);
addMsg('system','📊 회계 어시스턴트입니다. 세무·회계·재무분석을 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
+58 -1
View File
@@ -237,6 +237,17 @@ pollServerLogs();
let chatHistory = [];
let chatSessions = [];
let activeChatSessionId = null;
let _appCtxMax = 0;
async function _fetchAppCtxMax() {
if (_appCtxMax > 0) return;
try {
const r = await fetch('/api/model-context', { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
if (d.contextWindow) { _appCtxMax = d.contextWindow; renderSessionsList(); }
} catch (_) {}
}
let sessionsEditMode = false;
let isThinking = false;
let thinkingStartedAt = 0;
@@ -1827,6 +1838,15 @@ async function sendChat(queuedMessage = null) {
addProcessEntry('error', event.message);
break;
case 'usage': {
const pt = Number(event.promptTokens || 0);
if (pt > 0) {
const idx = chatSessions.findIndex(s => s.id === activeChatSessionId);
if (idx !== -1) { chatSessions[idx].contextTokens = pt; saveChatSessions(); renderSessionsList(); }
}
break;
}
case 'done':
finalReply = event.reply || '';
if (finalReply) partialContent = finalReply;
@@ -2366,6 +2386,7 @@ async function checkAuth() {
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
_fetchAppCtxMax();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
@@ -4350,6 +4371,7 @@ function renderSessionsList() {
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
${s.automated ? '<span class="session-auto-badge">Auto</span>' : ''}
<span class="badge badge-queued">${(s.history || []).length} msgs</span>
${(() => { const fmtTok = n => n>=1e6?`${(n/1e6).toFixed(1)}M`:n>=1000?`${Math.round(n/1000)}k`:`${n}`; const ct = Number(s.contextTokens||0) || Math.round((s.history||[]).reduce((a,m)=>a+(m.content||'').length,0)/4); const mx = _appCtxMax; if (!ct) return ''; if (mx > 0) { const pct = Math.min(100, Math.round(ct/mx*100)); const col = pct>=80?'var(--err)':pct>=50?'var(--warn)':'var(--muted)'; return `<span style="font-size:10px;color:${col}">${fmtTok(ct)}/${fmtTok(mx)}</span>`; } return `<span style="font-size:10px;color:var(--muted)">${fmtTok(ct)} tok</span>`; })()}
<span style="color:var(--muted);font-size:10px">${timeAgo(s.updatedAt || s.createdAt)}</span>
</div>
</div>
@@ -5189,6 +5211,7 @@ async function refreshAll() {
loadInstalledSkills();
loadSearchSettingsSummary();
loadAgentSessionContext();
_fetchAppCtxMax();
}
// ---- Log ----
@@ -5570,7 +5593,7 @@ function startMicRecording() {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50);
if (_micAutosend) setTimeout(function() { (_appSendFn || handleSendStop)(); }, 50);
}
};
_speechRecog.onerror = function(e) {
@@ -5592,6 +5615,40 @@ function stopMicRecording() {
var _ttsEnabled = localStorage.getItem('ttsEnabled') !== 'false'; // default on
var _ttsAudio = null;
var _ttsBtnActive = null;
var _appSendFn = null;
function initAppVoice(sendFn) {
_appSendFn = sendFn || null;
}
function playTTSText(btn, text) {
text = (text||'').replace(/```[\s\S]*?```/g,'').replace(/[#*`_~>]/g,' ').replace(/\s+/g,' ').trim();
if (!text) return;
if (_ttsBtnActive === btn && _ttsAudio) {
_ttsAudio.pause(); _ttsAudio = null;
btn.textContent = '🔊'; btn.classList.remove('playing');
_ttsBtnActive = null; return;
}
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); }
btn.textContent = '⌛'; btn.classList.add('playing');
_ttsBtnActive = btn;
fetch('/api/voice/tts', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
credentials: 'include',
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.blob(); })
.then(function(blob) {
var url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; };
_ttsAudio.onerror = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; };
btn.textContent = '⏹'; _ttsAudio.play();
})
.catch(function() { btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; });
}
function updateTTSMode() {
_ttsEnabled = document.getElementById('tts-mode-toggle').checked;
+4 -4
View File
@@ -320,16 +320,16 @@
.header h1{font-size:13px;}
.toolbar{gap:4px;}
.board-panel{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:84vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.5);
resize:none;
}
.board-panel.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid var(--bdr);height:52px;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--bdr);height:52px;}
.main{padding-bottom:52px;}
input,select{font-size:16px !important;}
}
@@ -7594,7 +7594,7 @@ function mobCloseBoard(){
document.getElementById('mobtab-serial').classList.add('active');
}
function mobToggleBoard(){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const open=document.querySelector('.board-panel').classList.contains('mob-open');
if(open){mobCloseBoard();}
else{
+5 -5
View File
@@ -181,7 +181,7 @@
.code-page-header > div:last-child{display:none;}
/* 좌측 파일 패널 → 왼쪽 오버레이 */
.code-sidebar-wrap{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:82vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.4);
@@ -189,7 +189,7 @@
.code-sidebar-wrap.mob-open{transform:translateX(0);}
/* 우측 AI 패널 → 오른쪽 오버레이 */
#code-ai-panel{
position:fixed !important;top:0;right:0;bottom:0;z-index:500;
position:fixed !important;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:400px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 28px rgba(0,0,0,.5);
@@ -203,9 +203,9 @@
grid-template-areas:"editor" "term" !important;
}
.code-vresize{display:none;}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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;}
.code-layout{padding-bottom:52px;}
input,select,textarea{font-size:16px !important;}
}
@@ -647,7 +647,7 @@ function mobCloseAll(){
document.getElementById('mobtab-editor').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const filesOpen=document.querySelector('.code-sidebar-wrap').classList.contains('mob-open');
const aiOpen=document.getElementById('code-ai-panel').classList.contains('mob-open');
if(which==='files'&&filesOpen){mobCloseAll();return;}
+6 -6
View File
@@ -641,7 +641,7 @@ button.danger:hover { background: #fecaca; }
/* Side panels → overlays */
.left-panel{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:82vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 28px rgba(0,0,0,.5);
@@ -649,18 +649,18 @@ button.danger:hover { background: #fecaca; }
}
.left-panel.mob-open{transform:translateX(0);}
.right-panel{
position:fixed;top:0;right:0;bottom:0;z-index:500;
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 28px rgba(0,0,0,.5);
}
.right-panel.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
/* Bottom tabbar */
#mob-tabbar{display:flex !important;}
#mob-tabbar{display:flex !important;z-index:600 !important;}
/* Center: leave bottom room for tabbar */
.main{padding-bottom:52px;}
@@ -695,7 +695,7 @@ button.danger:hover { background: #fecaca; }
<body>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none;position:fixed;bottom:0;left:0;right:0;height:52px;background:var(--panel);border-top:1px solid var(--border);z-index:490;align-items:stretch;">
<div id="mob-tabbar" style="display:none;position:fixed;bottom:0;left:0;right:0;height:52px;background:var(--panel);border-top:1px solid var(--border);z-index:600;align-items:stretch;">
<button class="mob-tab" id="mobtab-ctrl" onclick="mobToggle('left')" style="flex:1;border:none;border-radius:0;background:none;color:var(--muted);font-size:10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:0;cursor:pointer;">
<span style="font-size:18px">⚙</span>컨트롤
</button>
@@ -1639,7 +1639,7 @@ function mobCloseAll(){
if(rc)rc.style.display='none';
}
function mobToggle(side){
const isMob=window.innerWidth<=640;
const isMob=window.innerWidth<=1024;
if(!isMob)return;
const leftOpen=document.querySelector('.left-panel').classList.contains('mob-open');
const rightOpen=document.querySelector('.right-panel').classList.contains('mob-open');
+724 -13
View File
@@ -194,14 +194,14 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* ── Mobile ── */
@media(max-width:1024px){
.dt-resize{display:none;}
.dt-sb{position:fixed;top:0;left:0;bottom:0;z-index:500;width:82vw;max-width:290px;transform:translateX(-100%);transition:transform .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
.dt-sb{position:fixed;top:0;left:0;bottom:52px;z-index:500;width:82vw;max-width:290px;transform:translateX(-100%);transition:transform .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.4);}
.dt-sb.mob-open{transform:translateX(0);}
.dt-chat{position:fixed;top:0;right:0;bottom:0;z-index:500;width:94vw;max-width:390px;transform:translateX(100%);transition:transform .25s ease;box-shadow:-4px 0 24px rgba(0,0,0,.4);}
.dt-chat{position:fixed;top:0;right:0;bottom:52px;z-index:500;width:94vw;max-width:390px;transform:translateX(100%);transition:transform .25s ease;box-shadow:-4px 0 24px rgba(0,0,0,.4);}
.dt-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
#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;}
.dt-center{width:100%;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:400;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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;}
.dt-textarea,input,textarea{font-size:16px !important;}
.dt-case-hdr{flex-wrap:wrap;}
.dt-hdr-btns{width:100%;justify-content:flex-end;}
@@ -210,7 +210,17 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
#mob-tabbar{display:none !important;}
#mob-backdrop{display:none !important;}
}
.net-tab{background:none;border:none;border-bottom:2px solid transparent;padding:6px 12px;font-size:11px;font-weight:600;color:var(--muted);cursor:pointer;transition:.15s;}
.net-tab.active{color:#93c5fd;border-bottom-color:#93c5fd;}
.nmap-rtab{background:none;border:none;border-bottom:2px solid transparent;padding:4px 10px;font-size:10px;font-weight:600;color:var(--muted);cursor:pointer;}
.nmap-rtab.active{color:#4ade80;border-bottom-color:#4ade80;}
.nmap-port-row{display:grid;grid-template-columns:60px 55px 70px 1fr;gap:4px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:11px;align-items:center;}
.nmap-port-row.open{color:var(--fg);}
.nmap-port-row.filtered,.nmap-port-row.closed{color:var(--muted);opacity:.6;}
.hash-btn{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--fg);cursor:pointer;transition:.15s;}
.hash-btn:hover{background:var(--panel-3,#2a2a3a);border-color:#93c5fd;}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
@@ -271,6 +281,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<button class="dt-qbtn" onclick="sendQuick('디지털 발자국(Digital Footprint) 조사 방법을 알려주세요. SNS, 이메일, 도메인, IP 기반 공개 정보 추적 절차를 설명해 주세요.')">🌐 디지털 발자국 추적</button>
<button class="dt-qbtn" onclick="sendQuick('부동산 사기 유형과 사기 여부를 판별하는 방법을 알려주세요. 등기부등본 확인, 임대인 신원 확인, 이중계약 체크 방법도 포함해 주세요.')">🏠 부동산 사기 체크</button>
<button class="dt-qbtn" onclick="sendQuick('현재 사건의 단서와 증거를 바탕으로 가설을 세우고 조사 방향을 잡아주세요.',true)">🗂 현재 사건 분석 요청</button>
<button class="dt-qbtn" onclick="promptIPTrack()">🌐 IP 추적</button>
</div>
<div class="dt-sb-divider"></div>
@@ -369,6 +380,190 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
</div>
</div>
<!-- 네트워크 분석 패널 -->
<div id="center-ip" style="display:none;flex:1;flex-direction:column;min-height:0;overflow:hidden;">
<!-- 헤더 -->
<div style="display:flex;align-items:center;gap:8px;padding:10px 14px 0;flex-shrink:0;">
<span style="font-size:13px;font-weight:700;color:var(--fg)">🌐 네트워크 분석</span>
<button onclick="closeIPPanel()" style="margin-left:auto;background:none;border:1px solid var(--line);border-radius:5px;padding:2px 8px;font-size:11px;color:var(--muted);cursor:pointer">✕</button>
</div>
<!-- 타겟 입력 -->
<div style="display:flex;gap:6px;padding:8px 14px;flex-shrink:0;">
<input id="ip-input-field" type="text" placeholder="IP 또는 도메인 (예: 8.8.8.8, google.com)" style="flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:12px;color:var(--fg);outline:none;" onkeydown="if(event.key==='Enter')netTabAction()">
<button onclick="netTabAction()" style="background:#1e3a5f;border:none;border-radius:7px;padding:6px 14px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;" id="net-run-btn">실행</button>
</div>
<!-- 탭 -->
<div style="display:flex;gap:0;padding:0 14px;flex-shrink:0;border-bottom:1px solid var(--line);overflow-x:auto;">
<button class="net-tab active" onclick="switchNetTab('ip')" id="ntab-ip">📍 IP</button>
<button class="net-tab" onclick="switchNetTab('trace')" id="ntab-trace">🛤 Trace</button>
<button class="net-tab" onclick="switchNetTab('nmap')" id="ntab-nmap">🔍 Nmap</button>
<button class="net-tab" onclick="switchNetTab('dns')" id="ntab-dns">🌍 DNS</button>
<button class="net-tab" onclick="switchNetTab('ssl')" id="ntab-ssl">🔐 SSL</button>
<button class="net-tab" onclick="switchNetTab('vuln')" id="ntab-vuln">🛡 Vuln</button>
<button class="net-tab" onclick="switchNetTab('hash')" id="ntab-hash">🔑 해시</button>
<button class="net-tab" onclick="switchNetTab('webscan')" id="ntab-webscan">🕷 웹스캔</button>
<button class="net-tab" onclick="switchNetTab('dirb')" id="ntab-dirb">📂 디렉토리</button>
<button class="net-tab" onclick="switchNetTab('banner')" id="ntab-banner">🏷 배너</button>
<button class="net-tab" onclick="switchNetTab('cve')" id="ntab-cve">🔎 CVE</button>
</div>
<!-- 탭 콘텐츠 -->
<div style="flex:1;display:flex;flex-direction:column;min-height:0;">
<!-- IP 조회 탭 -->
<div id="npane-ip" style="flex:1;display:flex;flex-direction:column;min-height:0;padding:8px 14px;">
<div id="ip-result" style="display:none;font-size:11px;line-height:1.7;color:var(--fg);margin-bottom:6px;flex-shrink:0;"></div>
<div id="ip-map-wrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--line);min-height:0;">
<iframe id="ip-map-frame" style="width:100%;height:100%;border:none;" loading="lazy"></iframe>
</div>
</div>
<!-- Traceroute 탭 -->
<div id="npane-trace" style="display:none;flex:1;overflow:auto;padding:8px 14px;">
<div id="trace-status" style="font-size:11px;color:var(--muted);margin-bottom:6px;"></div>
<div id="trace-result" style="font-size:11px;font-family:monospace;line-height:1.6;color:var(--fg);overflow-x:auto;"></div>
</div>
<!-- 포트스캔 탭 -->
<div id="npane-nmap" style="display:none;flex-direction:column;gap:8px;flex:1;overflow:auto;padding:8px 14px;">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<select id="nmap-profile" onchange="nmapProfileChange()" style="background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);cursor:pointer;">
<option value="-T4 -F">Quick Scan (-T4 -F)</option>
<option value="-sn">Ping Scan (-sn)</option>
<option value="-T4 -sV">Version Detection (-T4 -sV)</option>
<option value="-T4 -A">Intense Scan (-T4 -A)</option>
<option value="-T4 -A -Pn">Intense, no ping (-T4 -A -Pn)</option>
<option value="-p 1-1024 -T4">Top 1024 Ports</option>
<option value="custom">Custom...</option>
</select>
<input id="nmap-custom-args" placeholder="nmap 인수..." style="display:none;flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);outline:none;min-width:140px;">
<span id="nmap-status" style="font-size:10px;color:var(--muted);margin-left:auto;"></span>
</div>
<div style="font-size:10px;color:var(--muted);font-family:monospace;background:var(--panel-2);padding:4px 8px;border-radius:5px;" id="nmap-cmd-preview"></div>
<!-- 결과 탭 -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--line);">
<button class="nmap-rtab active" onclick="switchNmapTab('ports')" id="nrtab-ports">포트</button>
<button class="nmap-rtab" onclick="switchNmapTab('host')" id="nrtab-host">호스트</button>
<button class="nmap-rtab" onclick="switchNmapTab('raw')" id="nrtab-raw">Raw</button>
</div>
<div id="nmap-pane-ports" style="font-size:11px;"></div>
<div id="nmap-pane-host" style="display:none;font-size:11px;line-height:1.8;"></div>
<div id="nmap-pane-raw" style="display:none;font-size:10px;font-family:monospace;white-space:pre;overflow-x:auto;color:var(--muted);"></div>
</div>
<!-- DNS/WHOIS 탭 -->
<div id="npane-dns" style="display:none;flex:1;flex-direction:column;gap:8px;overflow:auto;padding:8px 14px;">
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<select id="dns-type" style="background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);">
<option>A</option><option>AAAA</option><option>MX</option><option>NS</option>
<option>TXT</option><option>CNAME</option><option>SOA</option><option>ANY</option>
</select>
<button onclick="runDNS()" style="background:#1e3a5f;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">dig</button>
<button onclick="runWHOIS()" style="background:#1e3a5f;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">whois</button>
<span id="dns-status" style="font-size:10px;color:var(--muted);"></span>
</div>
<div id="dns-result" style="font-size:11px;font-family:monospace;white-space:pre-wrap;color:var(--fg);background:var(--panel-2);padding:10px;border-radius:8px;line-height:1.6;display:none;"></div>
</div>
<!-- SSL/HTTP 탭 -->
<div id="npane-ssl" style="display:none;flex:1;flex-direction:column;gap:8px;overflow:auto;padding:8px 14px;">
<div style="display:flex;gap:6px;align-items:center;">
<button onclick="runSSL()" style="background:#1e3a5f;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">분석</button>
<span id="ssl-status" style="font-size:10px;color:var(--muted);"></span>
</div>
<div style="display:flex;gap:0;border-bottom:1px solid var(--line);">
<button class="nmap-rtab active" onclick="switchSslTab('cert')" id="ssltab-cert">인증서</button>
<button class="nmap-rtab" onclick="switchSslTab('headers')" id="ssltab-headers">HTTP 헤더</button>
</div>
<div id="ssl-pane-cert" style="font-size:11px;font-family:monospace;white-space:pre-wrap;color:var(--fg);line-height:1.5;"></div>
<div id="ssl-pane-headers" style="display:none;font-size:11px;font-family:monospace;white-space:pre-wrap;color:var(--fg);line-height:1.6;"></div>
</div>
<!-- Vuln 스캔 탭 -->
<div id="npane-vuln" style="display:none;flex:1;flex-direction:column;gap:8px;overflow:auto;padding:8px 14px;">
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<select id="vuln-script" style="background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);">
<option value="safe">safe (안전 스크립트)</option>
<option value="vuln">vuln (취약점)</option>
<option value="auth">auth (인증 검사)</option>
<option value="ssl-enum-ciphers,ssl-cert,http-security-headers">SSL + HTTP 보안</option>
<option value="http-headers,http-methods,http-security-headers">HTTP 분석</option>
</select>
<button onclick="runVuln()" style="background:#7f1d1d;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#fca5a5;cursor:pointer;">스캔</button>
<span id="vuln-status" style="font-size:10px;color:var(--muted);"></span>
</div>
<div id="vuln-result" style="font-size:11px;font-family:monospace;white-space:pre-wrap;color:var(--fg);line-height:1.5;display:none;background:var(--panel-2);padding:10px;border-radius:8px;"></div>
</div>
<!-- 해시/인코딩 탭 -->
<div id="npane-hash" style="display:none;flex:1;flex-direction:column;gap:10px;overflow:auto;padding:8px 14px;">
<textarea id="hash-input" rows="3" placeholder="입력 텍스트..." style="background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:8px;font-size:12px;color:var(--fg);font-family:monospace;resize:vertical;outline:none;"></textarea>
<div style="display:flex;flex-wrap:wrap;gap:6px;">
<button onclick="hashDo('md5')" class="hash-btn">MD5</button>
<button onclick="hashDo('sha1')" class="hash-btn">SHA-1</button>
<button onclick="hashDo('sha256')" class="hash-btn">SHA-256</button>
<button onclick="hashDo('sha512')" class="hash-btn">SHA-512</button>
<button onclick="hashDo('b64enc')" class="hash-btn">B64 인코딩</button>
<button onclick="hashDo('b64dec')" class="hash-btn">B64 디코딩</button>
<button onclick="hashDo('urlenc')" class="hash-btn">URL 인코딩</button>
<button onclick="hashDo('urldec')" class="hash-btn">URL 디코딩</button>
<button onclick="hashDo('hex')" class="hash-btn">→ HEX</button>
<button onclick="hashDo('fromhex')" class="hash-btn">HEX →</button>
</div>
<div id="hash-output" style="display:none;background:var(--panel-2);border-radius:8px;padding:10px;font-size:11px;font-family:monospace;word-break:break-all;color:var(--fg);position:relative;">
<button onclick="copyHashResult()" style="position:absolute;top:6px;right:6px;background:none;border:1px solid var(--line);border-radius:4px;padding:2px 7px;font-size:10px;color:var(--muted);cursor:pointer;">복사</button>
<div id="hash-result-text"></div>
</div>
</div>
<!-- 웹스캔 탭 (nikto + whatweb) -->
<div id="npane-webscan" style="display:none;flex:1;flex-direction:column;gap:8px;overflow:auto;padding:8px 14px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<button onclick="runWhatWeb()" style="background:#1e3a5f;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">WhatWeb</button>
<button onclick="runNikto()" style="background:#7f1d1d;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#fca5a5;cursor:pointer;">Nikto</button>
<button id="webscan-stop-btn" onclick="stopWebscan()" style="display:none;background:none;border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;">⏹ 중지</button>
<span id="webscan-status" style="font-size:10px;color:var(--muted);"></span>
</div>
<div id="webscan-result" style="font-size:11px;font-family:monospace;white-space:pre-wrap;line-height:1.5;color:var(--fg);background:var(--panel-2);padding:10px;border-radius:8px;display:none;overflow:auto;flex:1;"></div>
</div>
<!-- 디렉토리 스캔 탭 (gobuster) -->
<div id="npane-dirb" style="display:none;flex:1;flex-direction:column;gap:8px;overflow:auto;padding:8px 14px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<select id="dirb-wordlist" style="background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);">
<option value="builtin">내장 (63개, 빠름)</option>
<option value="common">dirb/common (4600개)</option>
<option value="big">dirb/big (20000개)</option>
<option value="best1050">best1050 (빠름)</option>
</select>
<button onclick="runGobuster()" id="dirb-run-btn" style="background:#1e3a5f;border:none;border-radius:6px;padding:4px 12px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">스캔</button>
<button id="dirb-stop-btn" onclick="stopDirb()" style="display:none;background:none;border:1px solid var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;">⏹ 중지</button>
<span id="dirb-status" style="font-size:10px;color:var(--muted);"></span>
</div>
<div id="dirb-result" style="font-size:11px;font-family:monospace;white-space:pre-wrap;line-height:1.6;color:var(--fg);background:var(--panel-2);padding:10px;border-radius:8px;display:none;overflow:auto;flex:1;"></div>
</div>
<!-- 배너 그래빙 탭 -->
<div id="npane-banner" style="display:none;flex:1;flex-direction:column;gap:8px;padding:8px 14px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span style="font-size:11px;color:var(--muted);">포트:</span>
<input id="banner-port" type="number" value="80" min="1" max="65535" style="width:70px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--fg);outline:none;">
<div style="display:flex;flex-wrap:wrap;gap:4px;">
<button onclick="document.getElementById('banner-port').value=21" class="hash-btn">FTP</button>
<button onclick="document.getElementById('banner-port').value=22" class="hash-btn">SSH</button>
<button onclick="document.getElementById('banner-port').value=25" class="hash-btn">SMTP</button>
<button onclick="document.getElementById('banner-port').value=80" class="hash-btn">HTTP</button>
<button onclick="document.getElementById('banner-port').value=443" class="hash-btn">HTTPS</button>
<button onclick="document.getElementById('banner-port').value=3306" class="hash-btn">MySQL</button>
<button onclick="document.getElementById('banner-port').value=5432" class="hash-btn">PgSQL</button>
<button onclick="document.getElementById('banner-port').value=6379" class="hash-btn">Redis</button>
</div>
<span id="banner-status" style="font-size:10px;color:var(--muted);margin-left:auto;"></span>
</div>
<div id="banner-result" style="display:none;background:var(--panel-2);border-radius:8px;padding:10px;font-size:11px;font-family:monospace;white-space:pre-wrap;color:var(--fg);line-height:1.6;"></div>
</div>
<!-- CVE 검색 탭 -->
<div id="npane-cve" style="display:none;flex:1;flex-direction:column;gap:8px;padding:8px 14px;">
<div style="display:flex;gap:6px;">
<input id="cve-keyword" type="text" placeholder="키워드 (예: apache, openssl, log4j)" style="flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:12px;color:var(--fg);outline:none;" onkeydown="if(event.key==='Enter')searchCVE()">
<button onclick="searchCVE()" style="background:#1e3a5f;border:none;border-radius:7px;padding:6px 14px;font-size:11px;font-weight:700;color:#93c5fd;cursor:pointer;">검색</button>
</div>
<div id="cve-status" style="font-size:10px;color:var(--muted);"></div>
<div id="cve-result" style="display:none;flex:1;overflow:auto;"></div>
</div>
</div>
</div>
</section>
<!-- ── Resize ── -->
@@ -386,6 +581,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="dt-msgs" id="chat-msgs"></div>
<div class="dt-input-bar">
<textarea class="dt-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="dt-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -560,7 +756,7 @@ function selectCase(id){
renderCaseList();
if(activeTab==='checklist')renderChecklist();
if(activeTab==='timeline')renderTimeline();
if(window.innerWidth<=640)mobCloseAll();
if(window.innerWidth<=1024)mobCloseAll();
}
function newCase(){
@@ -666,7 +862,7 @@ ${notes||'(내용 없음)'}
체크리스트 항목만 줄바꿈으로 나열해주세요. 설명 없이 항목만 출력.`;
addMsg('user',escHtml('✨ AI 체크리스트 자동 생성 중...'));
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
const result=await callChatRaw(prompt);
if(result){
const items=result.split('\n').filter(l=>l.trim().startsWith('-')).map(l=>l.replace(/^-\s*/,'').trim()).filter(Boolean);
@@ -729,7 +925,7 @@ function askAiAboutCase(){
const msg=`다음 사건을 분석하고 조사 방향을 제안해주세요.\n\n**사건명:** ${title}\n**상태:** ${c?.status||'조사중'}\n\n**수집된 단서 및 메모:**\n${notes||'(없음)'}`;
addMsg('user',escHtml('사건 분석 요청: '+title));
callChat(msg);
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
}
async function generateReport(){
@@ -755,7 +951,7 @@ ${tlItems}
위 정보를 분석하여 공식 조사 보고서 형식으로 작성해주세요. 확인된 사실, 미확인 사항, 의심 정황, 종합 의견, 권고 사항을 포함하세요.`;
addMsg('user',escHtml('📄 보고서 생성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
}
async function generateComplaint(){
@@ -818,7 +1014,7 @@ ${tlItems}
작성 후 추가로 보완이 필요한 정보(피해금액, 증거자료 목록, 고소인 인적사항 등)를 정리해서 안내해 주세요.`;
addMsg('user',escHtml('⚖️ 고소장 작성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
}
async function guideSubmission(){
@@ -859,7 +1055,7 @@ web_search를 사용해 "${address} 관할 경찰서"와 "${address} 관할 검
고소장 제출 → 접수 → 수사 개시까지 단계별 절차를 간략히 안내해주세요.`;
addMsg('user',escHtml('📮 접수 기관 안내: '+c.title+(address?' ('+address+')':'')));
callChat(msg);
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
}
// ── Model ─────────────────────────────────────────────────────────────────────
@@ -1034,7 +1230,9 @@ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='dt-msg '+role;
const b=document.createElement('div');b.className='dt-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
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;
}
@@ -1058,17 +1256,529 @@ async function sendMessage(){
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg,withCase){
if(withCase&&currentCaseId){askAiAboutCase();return;}
if(window.innerWidth<=640)mobCloseAll();
if(window.innerWidth<=1024)mobCloseAll();
addMsg('user',escHtml(msg));callChat(msg);
}
// ── 네트워크 분석 패널 ───────────────────────────────────────────────
var _currentNetTab = 'ip';
var _allNetTabs = ['ip','trace','nmap','dns','ssl','vuln','hash','webscan','dirb','banner','cve'];
function promptIPTrack(){
document.getElementById('center-empty').style.display='none';
document.getElementById('center-case').style.display='none';
document.getElementById('center-ip').style.display='flex';
switchNetTab('ip');
nmapProfileChange();
setTimeout(()=>document.getElementById('ip-input-field').focus(),100);
}
function closeIPPanel(){
document.getElementById('center-ip').style.display='none';
if(currentCaseId){document.getElementById('center-case').style.display='flex';}
else{document.getElementById('center-empty').style.display='';}
}
function switchNetTab(tab){
_currentNetTab=tab;
_allNetTabs.forEach(t=>{
document.getElementById('ntab-'+t).classList.toggle('active',t===tab);
document.getElementById('npane-'+t).style.display=t===tab?'flex':'none';
});
const labels={ip:'조회',trace:'추적',nmap:'스캔',dns:'조회',ssl:'분석',vuln:'스캔',hash:'',webscan:'',dirb:'',banner:'그래빙',cve:''};
const noBtn=['hash','webscan','dirb','cve'];
const btn=document.getElementById('net-run-btn');
btn.style.display=noBtn.includes(tab)?'none':'';
btn.textContent=labels[tab]||'실행';
}
function netTabAction(){
const actions={ip:lookupIP,trace:runTraceroute,nmap:runNmap,dns:()=>runDNS(),ssl:runSSL,vuln:runVuln,banner:runBanner};
const fn=actions[_currentNetTab];
if(fn)fn();
}
function nmapProfileChange(){
const sel=document.getElementById('nmap-profile');
const custom=document.getElementById('nmap-custom-args');
const isCustom=sel.value==='custom';
custom.style.display=isCustom?'block':'none';
updateNmapCmd();
}
function updateNmapCmd(){
const target=(document.getElementById('ip-input-field').value||'<target>').trim();
const sel=document.getElementById('nmap-profile');
const args=sel.value==='custom'?(document.getElementById('nmap-custom-args').value||''):sel.value;
document.getElementById('nmap-cmd-preview').textContent=`nmap ${args} ${target}`;
}
document.addEventListener('input',e=>{
if(e.target.id==='ip-input-field'||e.target.id==='nmap-custom-args') updateNmapCmd();
});
function switchNmapTab(tab){
['ports','host','raw'].forEach(t=>{
document.getElementById('nrtab-'+t).classList.toggle('active',t===tab);
document.getElementById('nmap-pane-'+t).style.display=t===tab?'block':'none';
});
}
async function lookupIP(){
const ip=(document.getElementById('ip-input-field').value||'').trim();
if(!ip)return;
const result=document.getElementById('ip-result');
const mapWrap=document.getElementById('ip-map-wrap');
result.style.display='block';result.innerHTML='<span style="opacity:.5">조회 중...</span>';
mapWrap.style.display='none';
try{
const r=await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/json/`);
const d=await r.json();
if(d.error){result.innerHTML='<span style="color:var(--err)">'+escHtml(d.reason)+'</span>';return;}
const lat=d.latitude,lon=d.longitude;
result.innerHTML=`<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:var(--muted)">IP</span><span>${escHtml(d.ip)}</span>
<span style="color:var(--muted)">위치</span><span>${escHtml(d.country_name)} · ${escHtml(d.region||'')} · ${escHtml(d.city||'')}</span>
<span style="color:var(--muted)">시간대</span><span>${escHtml(d.timezone||'-')}</span>
<span style="color:var(--muted)">ISP</span><span>${escHtml(d.org||'-')}</span>
<span style="color:var(--muted)">ASN</span><span>${escHtml(d.asn||'-')}</span>
<span style="color:var(--muted)">좌표</span><span>${lat}, ${lon} <a href="https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=10/${lat}/${lon}" target="_blank" style="color:#38bdf8;font-size:10px;">🗺</a></span>
</div>`;
document.getElementById('ip-map-frame').src=
`https://www.openstreetmap.org/export/embed.html?bbox=${lon-0.3},${lat-0.2},${lon+0.3},${lat+0.2}&layer=mapnik&marker=${lat},${lon}`;
mapWrap.style.display='block';
callChat(`IP ${ip} 조회: 국가=${d.country_name}, 지역=${d.region||''}/${d.city||''}, ISP=${d.org||'-'}. 이 IP 위험 여부를 간략히 분석해주세요.`);
}catch(e){result.innerHTML='<span style="color:var(--err)">조회 실패</span>';}
}
async function runTraceroute(){
const ip=(document.getElementById('ip-input-field').value||'').trim();
if(!ip)return;
const btn=document.getElementById('net-run-btn');
const status=document.getElementById('trace-status');
const resultEl=document.getElementById('trace-result');
btn.disabled=true;btn.textContent='추적 중...';
status.textContent='mtr 실행 중 (약 15~30초)...';
resultEl.innerHTML='';
try{
const r=await fetch('/api/detective/traceroute',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({ip})});
const d=await r.json();
if(!d.success){resultEl.innerHTML='<span style="color:var(--err)">'+escHtml(d.error)+'</span>';return;}
const hubs=d.data?.report?.hubs||[];
status.textContent=`${hubs.length}개 홉`;
const hdr=`<div style="display:grid;grid-template-columns:24px 1fr 48px 48px 72px;gap:4px;padding:2px 0;border-bottom:1px solid var(--line);font-size:10px;color:var(--muted);">
<span>#</span><span>호스트</span><span style="text-align:right">손실</span><span style="text-align:right">평균</span><span style="text-align:right">최소/최대</span></div>`;
const rows=hubs.map(h=>{
const loss=h.Loss||0;
const col=loss>50?'#ef4444':loss>10?'#f59e0b':'#4ade80';
return `<div style="display:grid;grid-template-columns:24px 1fr 48px 48px 72px;gap:4px;padding:2px 0;border-bottom:1px solid rgba(255,255,255,.04);align-items:center;">
<span style="color:var(--muted)">${h.count}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(h.host||'???')}</span>
<span style="color:${col};text-align:right">${loss}%</span>
<span style="text-align:right;color:var(--muted)">${h.Avg||0}ms</span>
<span style="text-align:right;color:var(--muted)">${h.Best||0}/${h.Wrst||0}ms</span>
</div>`;
}).join('');
resultEl.innerHTML=hdr+rows;
}catch(e){resultEl.innerHTML='<span style="color:var(--err)">'+escHtml(e.message)+'</span>';}
finally{btn.disabled=false;btn.textContent='추적';}
}
async function runNmap(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const sel=document.getElementById('nmap-profile');
const argsStr=sel.value==='custom'?(document.getElementById('nmap-custom-args').value||''):sel.value;
const args=argsStr.trim().split(/\s+/).filter(Boolean);
const btn=document.getElementById('net-run-btn');
const status=document.getElementById('nmap-status');
btn.disabled=true;btn.textContent='스캔 중...';
status.textContent='실행 중...';
document.getElementById('nmap-pane-ports').innerHTML='';
document.getElementById('nmap-pane-host').innerHTML='';
document.getElementById('nmap-pane-raw').textContent='';
try{
const r=await fetch('/api/detective/nmap',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,args})});
const d=await r.json();
if(!d.success){status.textContent='오류';document.getElementById('nmap-pane-ports').innerHTML='<span style="color:var(--err)">'+escHtml(d.error)+'</span>';return;}
parseNmapXml(d.xml,target);
}catch(e){status.textContent='오류';document.getElementById('nmap-pane-ports').innerHTML='<span style="color:var(--err)">'+escHtml(e.message)+'</span>';}
finally{btn.disabled=false;btn.textContent='스캔';}
}
function parseNmapXml(xml,target){
const doc=new DOMParser().parseFromString(xml,'text/xml');
const host=doc.querySelector('host');
const status=host?.querySelector('status');
const state=status?.getAttribute('state')||'unknown';
const addrs=[...doc.querySelectorAll('address')].map(a=>`${a.getAttribute('addrtype')}: ${a.getAttribute('addr')}`).join(' / ');
const hostnames=[...doc.querySelectorAll('hostname')].map(h=>h.getAttribute('name')).filter(Boolean).join(', ')||'-';
const ports=[...doc.querySelectorAll('port')];
const osMatch=doc.querySelector('osmatch');
const elapsed=doc.querySelector('finished')?.getAttribute('elapsed')||'?';
// 포트 탭
const portHdr=`<div style="display:grid;grid-template-columns:60px 55px 70px 1fr;gap:4px;padding:2px 0;border-bottom:1px solid var(--line);font-size:10px;color:var(--muted);">
<span>포트</span><span>상태</span><span>프로토콜</span><span>서비스</span></div>`;
if(ports.length){
const rows=ports.map(p=>{
const ps=p.querySelector('state')?.getAttribute('state')||'';
const svc=p.querySelector('service');
const name=svc?.getAttribute('name')||'';
const prod=svc?.getAttribute('product')||'';
const ver=svc?.getAttribute('version')||'';
const col=ps==='open'?'#4ade80':ps==='filtered'?'#f59e0b':'#6b7280';
return `<div class="nmap-port-row ${ps}">
<span style="color:#93c5fd">${escHtml(p.getAttribute('portid'))}</span>
<span style="color:${col}">${ps}</span>
<span style="color:var(--muted)">${escHtml(p.getAttribute('protocol'))}</span>
<span>${escHtml([name,prod,ver].filter(Boolean).join(' '))}</span>
</div>`;
}).join('');
document.getElementById('nmap-pane-ports').innerHTML=portHdr+rows;
}else{
document.getElementById('nmap-pane-ports').innerHTML=portHdr+'<div style="color:var(--muted);padding:6px 0;font-size:11px;">열린 포트 없음</div>';
}
// 호스트 탭
document.getElementById('nmap-pane-host').innerHTML=`<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 14px;">
<span style="color:var(--muted)">대상</span><span>${escHtml(target)}</span>
<span style="color:var(--muted)">상태</span><span style="color:${state==='up'?'#4ade80':'#ef4444'}">${state}</span>
<span style="color:var(--muted)">주소</span><span>${escHtml(addrs)}</span>
<span style="color:var(--muted)">호스트명</span><span>${escHtml(hostnames)}</span>
${osMatch?`<span style="color:var(--muted)">OS</span><span>${escHtml(osMatch.getAttribute('name')||'')} (${osMatch.getAttribute('accuracy')}%)</span>`:''}
<span style="color:var(--muted)">소요시간</span><span>${elapsed}s</span>
</div>`;
// Raw 탭
document.getElementById('nmap-pane-raw').textContent=xml;
const openCnt=ports.filter(p=>p.querySelector('state')?.getAttribute('state')==='open').length;
document.getElementById('nmap-status').textContent=`${state} · 열린포트 ${openCnt}개 · ${elapsed}s`;
}
// ── DNS / WHOIS ──────────────────────────────────────────────────────
async function runDNS(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const type=document.getElementById('dns-type').value;
const status=document.getElementById('dns-status');
const result=document.getElementById('dns-result');
status.textContent='dig 실행 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/dns',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,type})});
const d=await r.json();
status.textContent=d.success?`${type} 조회 완료`:'오류';
result.style.display='block';
result.textContent=d.success?(d.output||'(결과 없음)'):d.error;
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
async function runWHOIS(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('dns-status');
const result=document.getElementById('dns-result');
status.textContent='whois 조회 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/whois',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
status.textContent=d.success?'whois 완료':'오류';
result.style.display='block';
result.textContent=d.success?d.output:d.error;
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
// ── SSL/HTTP ─────────────────────────────────────────────────────────
function switchSslTab(tab){
['cert','headers'].forEach(t=>{
document.getElementById('ssltab-'+t).classList.toggle('active',t===tab);
document.getElementById('ssl-pane-'+t).style.display=t===tab?'block':'none';
});
}
function parseCert(raw){
const lines=raw.split('\n');
const out=[];
let inCert=false;
for(const l of lines){
if(l.includes('Certificate chain')||l.includes('subject=')||l.includes('issuer=')||l.includes('notBefore')||l.includes('notAfter')||l.includes('Verify return code'))
out.push(l.trim());
if(l.includes('-----BEGIN CERTIFICATE-----'))inCert=true;
if(inCert)continue;
}
return out.join('\n')||raw.slice(0,3000);
}
function colorHeaders(raw){
return raw.split('\n').map(l=>{
const sec=['Strict-Transport-Security','Content-Security-Policy','X-Frame-Options','X-Content-Type','X-XSS','Referrer-Policy','Permissions-Policy'];
const bad=['Server:','X-Powered-By:'];
if(sec.some(s=>l.startsWith(s)))return`<span style="color:#4ade80">${escHtml(l)}</span>`;
if(bad.some(s=>l.startsWith(s)))return`<span style="color:#f59e0b">${escHtml(l)}</span>`;
return escHtml(l);
}).join('\n');
}
async function runSSL(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('ssl-status');
status.textContent='분석 중...';
document.getElementById('ssl-pane-cert').textContent='';
document.getElementById('ssl-pane-headers').innerHTML='';
try{
const r=await fetch('/api/detective/ssl',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
if(!d.success){status.textContent='오류: '+d.error;return;}
document.getElementById('ssl-pane-cert').textContent=parseCert(d.cert||'');
document.getElementById('ssl-pane-headers').innerHTML=colorHeaders(d.headers||'');
status.textContent='완료';
}catch(e){status.textContent='오류: '+e.message;}
}
// ── Vuln Scan (SSE 스트리밍) ──────────────────────────────────────────
var _vulnAbort = null;
async function runVuln(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const scripts=document.getElementById('vuln-script').value;
const status=document.getElementById('vuln-status');
const result=document.getElementById('vuln-result');
const btn=document.getElementById('net-run-btn');
btn.textContent='⏹ 중지';btn.onclick=stopVuln;
status.textContent='스캔 중...';
result.style.display='block';result.textContent='';
_vulnAbort=new AbortController();
try{
const r=await fetch('/api/detective/vuln',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,scripts}),signal:_vulnAbort.signal});
if(!r.ok){result.textContent='서버 오류: '+r.status;status.textContent='오류';return;}
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){
const dataLine=part.split('\n').find(l=>l.startsWith('data:'));
if(!dataLine)continue;
try{
const obj=JSON.parse(dataLine.slice(5).trim());
if(obj.done){status.textContent=obj.error?'오류: '+obj.error:'완료';return;}
if(obj.line){
const isErr=obj.line.includes('Script execution failed')||obj.line.includes('ERROR:');
const span=document.createElement('span');
span.style.cssText=isErr?'opacity:.35;':'';
span.textContent=obj.line+'\n';
result.appendChild(span);
result.scrollTop=result.scrollHeight;
}
}catch{}
}
}
vulnReport(result.textContent,target,scripts);
}catch(e){
if(e.name==='AbortError')status.textContent='중지됨';
else{status.textContent='오류';result.textContent=e.message;}
}finally{
_vulnAbort=null;
btn.textContent='스캔';btn.onclick=netTabAction;
}
}
function vulnReport(raw, target, scripts){
const lines=raw.split('\n').filter(l=>l.trim()&&!l.includes('Script execution failed')&&!l.includes('Starting Nmap')&&!l.includes('Nmap done'));
const summary=lines.slice(0,80).join('\n');
callChat(`다음은 ${target}에 대한 nmap --script ${scripts} 스캔 결과야.\n\n\`\`\`\n${summary}\n\`\`\`\n\n보안 관점에서 주요 발견사항과 위험도를 간단히 요약해줘. 발견된 취약점, 열린 포트, 주의사항 위주로.`);
}
function stopVuln(){
if(_vulnAbort){_vulnAbort.abort();_vulnAbort=null;}
}
// ── 해시 / 인코딩 ────────────────────────────────────────────────────
async function hashDo(type){
const text=document.getElementById('hash-input').value||'';
let result='';
try{
if(type==='b64enc'){result=btoa(unescape(encodeURIComponent(text)));}
else if(type==='b64dec'){result=decodeURIComponent(escape(atob(text)));}
else if(type==='urlenc'){result=encodeURIComponent(text);}
else if(type==='urldec'){result=decodeURIComponent(text);}
else if(type==='hex'){result=Array.from(new TextEncoder().encode(text)).map(b=>b.toString(16).padStart(2,'0')).join(' ');}
else if(type==='fromhex'){result=new TextDecoder().decode(new Uint8Array(text.trim().split(/\s+/).map(h=>parseInt(h,16))));}
else{
const algo={'md5':null,'sha1':'SHA-1','sha256':'SHA-256','sha512':'SHA-512'}[type];
if(!algo){result='MD5는 브라우저 내장 미지원 (서버 해시 필요)';}
else{const buf=await crypto.subtle.digest(algo,new TextEncoder().encode(text));result=Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');}
}
}catch(e){result='오류: '+e.message;}
document.getElementById('hash-result-text').textContent=result;
document.getElementById('hash-output').style.display='block';
}
function copyHashResult(){
const text=document.getElementById('hash-result-text').textContent;
navigator.clipboard.writeText(text).catch(()=>{});
}
// ── SSE 스트리밍 헬퍼 ────────────────────────────────────────────────
async function sseStream(url, body, resultEl, statusEl, stopBtnId, onDone){
const abortCtrl = new AbortController();
if(stopBtnId) document.getElementById(stopBtnId).style.display='inline-block';
window['_abort_'+stopBtnId]=abortCtrl;
resultEl.style.display='block'; resultEl.textContent='';
try{
const r=await fetch(url,{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify(body),signal:abortCtrl.signal});
if(!r.ok){resultEl.textContent='서버 오류: '+r.status;return;}
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){
const dl=part.split('\n').find(l=>l.startsWith('data:'));
if(!dl)continue;
try{
const obj=JSON.parse(dl.slice(5).trim());
if(obj.done){if(statusEl)statusEl.textContent=obj.error?'오류: '+obj.error:'완료';if(onDone)onDone(resultEl.textContent);return;}
if(obj.line){resultEl.textContent+=obj.line+'\n';resultEl.scrollTop=resultEl.scrollHeight;}
}catch{}
}
}
}catch(e){
if(e.name==='AbortError'){if(statusEl)statusEl.textContent='중지됨';}
else{if(statusEl)statusEl.textContent='오류';resultEl.textContent=e.message;}
}finally{
if(stopBtnId)document.getElementById(stopBtnId).style.display='none';
}
}
// ── WhatWeb + Nikto ───────────────────────────────────────────────────
var _webscanAbort=null;
async function runWhatWeb(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('webscan-status');
const result=document.getElementById('webscan-result');
status.textContent='WhatWeb 분석 중...';
result.style.display='block';result.textContent='';
try{
const r=await fetch('/api/detective/whatweb',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
result.textContent=d.output||d.error;
status.textContent='완료';
callChat(`WhatWeb 결과 (${target}):\n${(d.output||'').slice(0,500)}\n\n기술 스택과 보안 관점 요약해줘.`);
}catch(e){status.textContent='오류';result.textContent=e.message;}
}
async function runNikto(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('webscan-status');
const result=document.getElementById('webscan-result');
status.textContent='Nikto 스캔 중 (수분 소요)...';
document.getElementById('webscan-stop-btn').style.display='inline-block';
await sseStream('/api/detective/nikto',{target},result,status,'webscan-stop-btn',
(out)=>callChat(`Nikto 스캔 결과 (${target}):\n${out.slice(0,1000)}\n\n주요 취약점과 위험도 요약해줘.`));
}
function stopWebscan(){
const a=window['_abort_webscan-stop-btn'];
if(a)a.abort();
document.getElementById('webscan-stop-btn').style.display='none';
}
// ── Gobuster ──────────────────────────────────────────────────────────
async function runGobuster(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const wordlist=document.getElementById('dirb-wordlist').value;
const status=document.getElementById('dirb-status');
const result=document.getElementById('dirb-result');
const runBtn=document.getElementById('dirb-run-btn');
runBtn.disabled=true;
status.textContent='스캔 중...';
await sseStream('/api/detective/gobuster',{target,wordlist},result,status,'dirb-stop-btn',
(out)=>{
const found=out.split('\n').filter(l=>l.trim()&&!l.startsWith('Error')).length;
status.textContent=`완료 — ${found}개 발견`;
});
runBtn.disabled=false;
}
function stopDirb(){
const a=window['_abort_dirb-stop-btn'];
if(a)a.abort();
document.getElementById('dirb-stop-btn').style.display='none';
document.getElementById('dirb-run-btn').disabled=false;
}
// ── 배너 그래빙 ───────────────────────────────────────────────────────
async function runBanner(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const port=parseInt(document.getElementById('banner-port').value)||80;
const status=document.getElementById('banner-status');
const result=document.getElementById('banner-result');
status.textContent='연결 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/banner',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,port})});
const d=await r.json();
result.style.display='block';
result.textContent=d.success?d.output:d.error;
status.textContent=d.success?`포트 ${port} 응답`:'오류';
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
// ── CVE 검색 ─────────────────────────────────────────────────────────
async function searchCVE(){
const keyword=(document.getElementById('cve-keyword').value||'').trim();
if(!keyword)return;
const status=document.getElementById('cve-status');
const result=document.getElementById('cve-result');
status.textContent='NVD 검색 중...';result.style.display='none';
try{
const r=await fetch(`/api/detective/cve?keyword=${encodeURIComponent(keyword)}`,{headers:authH(),credentials:'include'});
const d=await r.json();
if(!d.success){status.textContent='오류: '+d.error;return;}
const vulns=d.data?.vulnerabilities||[];
status.textContent=`${d.data?.totalResults||0}건 중 ${vulns.length}건 표시`;
if(!vulns.length){result.style.display='block';result.innerHTML='<div style="color:var(--muted);font-size:11px;">결과 없음</div>';return;}
result.style.display='block';
result.innerHTML=vulns.map(v=>{
const cve=v.cve;
const id=cve.id;
const desc=(cve.descriptions?.find(d=>d.lang==='en')?.value||'').slice(0,200);
const score=cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseScore||cve.metrics?.cvssMetricV2?.[0]?.cvssData?.baseScore||'N/A';
const sev=cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseSeverity||'';
const col=sev==='CRITICAL'?'#ef4444':sev==='HIGH'?'#f59e0b':sev==='MEDIUM'?'#eab308':'#6b7280';
const published=(cve.published||'').slice(0,10);
return `<div style="background:var(--panel-2);border-radius:8px;padding:10px;margin-bottom:8px;border-left:3px solid ${col};">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:12px;font-weight:700;color:#93c5fd;">${escHtml(id)}</span>
<span style="font-size:10px;font-weight:700;color:${col};">${sev||'?'} ${score}</span>
<span style="font-size:10px;color:var(--muted);margin-left:auto;">${published}</span>
</div>
<div style="font-size:11px;color:var(--fg);line-height:1.5;">${escHtml(desc)}${desc.length>=200?'…':''}</div>
<a href="https://nvd.nist.gov/vuln/detail/${encodeURIComponent(id)}" target="_blank" style="font-size:10px;color:#38bdf8;">자세히 보기 →</a>
</div>`;
}).join('');
}catch(e){status.textContent='오류';result.style.display='block';result.innerHTML=escHtml(e.message);}
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
@@ -1173,6 +1883,7 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
loadSkill();
await Promise.all([loadCases(),loadModels()]);
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
+4 -4
View File
@@ -93,15 +93,15 @@
.toolbar{gap:4px;}
.toolbar .btn{padding:5px 8px;font-size:11px;}
.left-panel{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:84vw;max-width:310px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.6);
}
.left-panel.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid #2d2d4a;height:52px;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid #2d2d4a;height:52px;}
.main{padding-bottom:52px;}
.serial-panel{border:none;}
input,select{font-size:16px !important;}
@@ -510,7 +510,7 @@ function mobClosePanel(){
document.getElementById('mobtab-term').classList.add('active');
}
function mobTogglePanel(){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const open=document.querySelector('.left-panel').classList.contains('mob-open');
if(open){mobClosePanel();}
else{
+3
View File
@@ -615,6 +615,9 @@
<span id="r-heartbeat-state" style="display:none"></span>
<span id="r-heartbeat-meta" style="display:none"></span>
<div class="task-progress-wrap"><div class="task-progress-fill" id="r-task-progress-bar"></div></div>
<!-- hidden placeholders kept so JS doesn't error -->
<span id="r-ctx-fill" style="display:none"></span>
<span id="r-ctx-label" style="display:none"></span>
<div class="agent-context-scroll" id="r-agent-context-scroll">
<div class="task-list" id="r-turn-execution">
<div style="font-size:11px;color:var(--muted)">활성 턴 실행 없음.</div>
+14 -9
View File
@@ -136,23 +136,23 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
@media(max-width:1024px){
.iv-resize{display:none;}
.iv-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
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);
}
.iv-sb.mob-open{transform:translateX(0);}
.iv-chat{
position:fixed;top:0;right:0;bottom:0;z-index:500;
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);
}
.iv-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
#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;}
.iv-center{width:100%;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:400;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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;}
.iv-textarea,input,textarea{font-size:16px !important;}
}
@media(min-width:1025px){
@@ -160,6 +160,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
#mob-backdrop{display:none !important;}
}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
@@ -256,6 +257,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="iv-msgs" id="chat-msgs"></div>
<div class="iv-input-bar">
<textarea class="iv-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="iv-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -585,7 +587,7 @@ function showEmpty(){
// Korean stock via Naver Finance image
function loadKStock(code,name,period){
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
period=period||document.getElementById('period-sel').value||'day';
setChartType('kr');
document.getElementById('sym-input').value=code;
@@ -611,7 +613,7 @@ function loadKStock(code,name,period){
// TradingView widget
function loadTV(symbol,interval,name){
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
interval=interval||'D';
setChartType('tv');
document.getElementById('sym-input').value=symbol;
@@ -690,7 +692,9 @@ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='iv-msg '+role;
const b=document.createElement('div');b.className='iv-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
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;
}
@@ -762,13 +766,13 @@ async function sendMessage(){
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=640) mobToggle('chat');
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){
if(window.innerWidth<=640){mobCloseAll();}
if(window.innerWidth<=1024){mobCloseAll();}
addMsg('user',escHtml(msg));callChat(msg);
}
@@ -839,6 +843,7 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
setChartType('kr');
await Promise.all([loadSkill(),loadModels()]);
addMsg('system','📈 투자 어시스턴트입니다. 종목 분석, 포트폴리오 구성, 경제지표를 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
+4 -4
View File
@@ -178,7 +178,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
/* Sidebar: overlay drawer */
.lg-sidebar {
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:80vw;max-width:280px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.45);
@@ -188,7 +188,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
.csb-tab-bar { display:none; }
/* all sb-* content visible in drawer (setNav handles show/hide) */
#mob-backdrop { display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499; }
#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; }
/* Main takes full width */
@@ -200,7 +200,7 @@ body { background: var(--bg); color: var(--text); font-family: 'Nanum Gothic', s
/* Bottom tabbar */
#mob-tabbar {
display:flex !important;
position:fixed;bottom:0;left:0;right:0;z-index:400;
position:fixed;bottom:0;left:0;right:0;z-index:600;
background:var(--panel);border-top:1px solid var(--line);
height:52px;
}
@@ -492,7 +492,7 @@ function mobNav(nav){
// hook category/filter buttons to auto-close sidebar on mobile
['setCategory','setVocabCat','setQuizCat'].forEach(fn=>{
const orig=window[fn];
if(orig) window[fn]=function(...a){orig(...a);if(window.innerWidth<=640)mobCloseFilter();};
if(orig) window[fn]=function(...a){orig(...a);if(window.innerWidth<=1024)mobCloseFilter();};
});
// ═══════════════════════════════════════════════════════
+13 -8
View File
@@ -136,24 +136,24 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
@media(max-width:1024px){
.lw-resize{display:none;}
.lw-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
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);
}
.lw-sb.mob-open{transform:translateX(0);}
.lw-chat{
position:fixed;top:0;right:0;bottom:0;z-index:500;
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);
}
.lw-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
#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;}
.lw-center{width:100%;}
.lw-center-body{padding-bottom:64px;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:400;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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;}
.lw-textarea,input,textarea{font-size:16px !important;}
}
@media(min-width:1025px){
@@ -161,6 +161,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
#mob-backdrop{display:none !important;}
}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
@@ -275,6 +276,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="lw-msgs" id="chat-msgs"></div>
<div class="lw-input-bar">
<textarea class="lw-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="lw-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -445,7 +447,7 @@ function renderResults(data,query){
}
async function selectResult(idx){
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
document.querySelectorAll('.lw-result-item').forEach(el=>el.classList.remove('active'));
const el=document.getElementById('ri-'+idx);
if(el)el.classList.add('active');
@@ -518,7 +520,9 @@ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='lw-msg '+role;
const b=document.createElement('div');b.className='lw-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
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;
}
@@ -542,13 +546,13 @@ async function sendMessage(){
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=640) mobToggle('chat');
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg){
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
addMsg('user',escHtml(msg));callChat(msg);
}
@@ -611,6 +615,7 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
await loadHistory();
await Promise.all([loadSkill(),loadModels()]);
addMsg('system','⚖️ 법률 어시스턴트입니다. 법령/판례 검색이나 법률 질문을 입력하세요.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
+12 -7
View File
@@ -166,7 +166,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.mn-resize{display:none;}
/* sidebar & chat become fixed overlays */
.mn-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:80vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
border-right:1px solid var(--line);
@@ -174,7 +174,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
}
.mn-sb.mob-open{transform:translateX(0);}
.mn-chat{
position:fixed;top:0;right:0;bottom:0;z-index:500;
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:380px;
transform:translateX(100%);transition:transform .25s ease;
border-left:1px solid var(--line);
@@ -182,14 +182,14 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
}
.mn-chat.mob-open{transform:translateX(0);}
/* overlay backdrop */
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
#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;}
/* center takes full width */
.mn-center{width:100%;}
/* bottom tab bar */
#mob-tabbar{
display:flex !important;
position:fixed;bottom:0;left:0;right:0;z-index:400;
position:fixed;bottom:0;left:0;right:0;z-index:600;
background:var(--panel);border-top:1px solid var(--line);
height:52px;
}
@@ -243,6 +243,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.mn-send:hover:not(:disabled){background:#6d28d9;}
.mn-send:disabled{opacity:.5;cursor:not-allowed;}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
@@ -362,6 +363,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<div class="mn-msgs" id="chat-msgs"></div>
<div class="mn-input-bar">
<textarea class="mn-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="mn-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -523,7 +525,7 @@ const toolTitles={
function showTool(name){
// on mobile: close sidebar overlay after selecting a tool
if(window.innerWidth<=640) mobCloseAll();
if(window.innerWidth<=1024) mobCloseAll();
// stop breath timer if switching
if(breathTimer&&name!=='breath'){clearInterval(breathTimer);breathTimer=null;}
activeTool=name;
@@ -1018,7 +1020,9 @@ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='mn-msg '+role;
const b=document.createElement('div');b.className='mn-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
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;
}
@@ -1048,7 +1052,7 @@ async function sendMessage(){
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
// on mobile: open chat panel so user can see the response
if(window.innerWidth<=640) mobToggle('chat');
if(window.innerWidth<=1024) mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
@@ -1114,6 +1118,7 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
setMode(currentMode);
await Promise.all([loadSkills(),loadModels()]);
addMsg('system','🧠 마음 상담실에 오신 걸 환영합니다. 왼쪽 도구를 활용하거나 편하게 이야기해주세요.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
+6 -6
View File
@@ -462,7 +462,7 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
.ms-hdr h1{font-size:13px;}
/* Sidebar → left overlay */
.ms-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:82vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 28px rgba(0,0,0,.5);
@@ -470,16 +470,16 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
.ms-sb.mob-open{transform:translateX(0);}
/* Chat → right overlay */
.ms-chat{
position:fixed;top:0;right:0;bottom:0;z-index:500;
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:360px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 28px rgba(0,0,0,.5);
}
.ms-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--studio-panel);border-top:1px solid var(--studio-border);height:52px;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--studio-panel);border-top:1px solid var(--studio-border);height:52px;}
.ms-layout{padding-bottom:52px;}
@@ -3229,7 +3229,7 @@ function mobCloseAll(){
document.getElementById('mobtab-stage').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const filesOpen=document.querySelector('.ms-sb').classList.contains('mob-open');
const aiOpen=document.querySelector('.ms-chat').classList.contains('mob-open');
if(which==='files'&&filesOpen){mobCloseAll();return;}
@@ -3252,7 +3252,7 @@ async function sendMessage() {
const input = document.getElementById('chat-input');
const msg = input.value.trim();
if (!msg || document.getElementById('send-btn').disabled) return;
if(window.innerWidth<=640)mobToggle('ai');
if(window.innerWidth<=1024)mobToggle('ai');
input.value = ''; autoResize(input);
addChatMsg('user', escHtml(msg));
await callChat(msg);
+4 -4
View File
@@ -277,7 +277,7 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
/* Sidebar: left overlay */
.sidebar{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:82vw;max-width:310px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 28px rgba(0,0,0,.5);
@@ -286,9 +286,9 @@ body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,san
.sidebar.mob-open{transform:translateX(0);}
.sidebar-mob-theme{display:flex !important;}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--bg);border-top:1px solid var(--line);height:52px;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--bg);border-top:1px solid var(--line);height:52px;}
/* bottom padding for tabbar */
.body{padding-bottom:52px;}
@@ -827,7 +827,7 @@ function mobCloseAll(){
document.getElementById('mobtab-work').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const open=document.querySelector('.sidebar').classList.contains('mob-open');
if(which==='settings'&&open){mobCloseAll();return;}
mobCloseAll();
+6
View File
@@ -1063,6 +1063,12 @@
.tts-btn { background:none;border:none;cursor:pointer;font-size:13px;padding:1px 4px;border-radius:4px;opacity:0.45;vertical-align:middle;line-height:1; }
.tts-btn:hover,.tts-btn.playing{opacity:1;background:var(--panel-2);}
body.tts-disabled .tts-btn { display:none !important; }
.app-mic-btn { background:none;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:7px 10px;font-size:16px;cursor:pointer;flex-shrink:0;transition:.15s;color:var(--fg);line-height:1; }
.app-mic-btn:hover { background:var(--panel-2); }
.app-mic-btn.recording { background:#ef4444!important;animation:mic-pulse 1s ease-in-out infinite; }
.app-tts-btn { background:none;border:none;cursor:pointer;font-size:12px;padding:2px 5px;border-radius:4px;opacity:0.4;display:block;margin-top:4px; }
.app-tts-btn:hover,.app-tts-btn.playing { opacity:1;background:rgba(255,255,255,.08); }
body.tts-disabled .app-tts-btn { display:none !important; }
.export-dropdown { position:relative; display:inline-block; }
.export-menu { display:none; position:absolute; right:0; top:calc(100% + 4px); background:var(--panel-2); border:1px solid var(--border); border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,0.18); z-index:2000; min-width:160px; overflow:hidden; }
.export-menu button { display:block; width:100%; padding:8px 14px; text-align:left; background:none; border:none; cursor:pointer; font-size:12px; color:var(--text); white-space:nowrap; }
+83
View File
@@ -0,0 +1,83 @@
// Shared voice input + TTS for sub-apps. Requires authH() to be defined by the host page.
var _appSendFn = null;
var _isRecording = false;
var _speechRecog = null;
var _micAutosend = true;
var _ttsAudio = null;
var _ttsBtnActive = null;
function initAppVoice(sendFn) {
_appSendFn = sendFn || null;
}
function toggleMicRecording() {
if (_isRecording) stopMicRecording();
else startMicRecording();
}
function startMicRecording() {
var SpeechRecog = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecog) { alert('이 브라우저는 음성 인식을 지원하지 않습니다.\nChrome을 사용해주세요.'); return; }
_speechRecog = new SpeechRecog();
_speechRecog.lang = 'ko-KR';
_speechRecog.interimResults = false;
_speechRecog.maxAlternatives = 1;
_speechRecog.continuous = false;
var btn = document.getElementById('mic-btn');
_speechRecog.onstart = function() {
_isRecording = true;
if (btn) { btn.textContent = '⏹'; btn.title = '말하는 중… (클릭 시 중지)'; btn.classList.add('recording'); }
};
_speechRecog.onresult = function(e) {
var transcript = e.results[0][0].transcript;
var input = document.getElementById('chat-input');
if (input && transcript) {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
if (_micAutosend && _appSendFn) setTimeout(function() { _appSendFn(); }, 50);
}
};
_speechRecog.onerror = function(e) {
if (e.error !== 'no-speech') alert('음성 인식 오류: ' + e.error);
};
_speechRecog.onend = function() {
_isRecording = false;
_speechRecog = null;
if (btn) { btn.textContent = '🎤'; btn.title = '음성 입력'; btn.classList.remove('recording'); }
};
_speechRecog.start();
}
function stopMicRecording() {
if (_speechRecog) _speechRecog.stop();
}
function playTTSText(btn, text) {
text = (text || '').replace(/```[\s\S]*?```/g, '').replace(/[#*`_~>]/g, ' ').replace(/\s+/g, ' ').trim();
if (!text) return;
if (_ttsBtnActive === btn && _ttsAudio) {
_ttsAudio.pause(); _ttsAudio = null;
btn.textContent = '🔊'; btn.classList.remove('playing');
_ttsBtnActive = null; return;
}
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
if (_ttsBtnActive) { _ttsBtnActive.textContent = '🔊'; _ttsBtnActive.classList.remove('playing'); }
btn.textContent = '⌛'; btn.classList.add('playing');
_ttsBtnActive = btn;
fetch('/api/voice/tts', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, typeof authH === 'function' ? authH() : {}),
credentials: 'include',
body: JSON.stringify({ text: text.slice(0, 2000) }),
})
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.blob(); })
.then(function(blob) {
var url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; };
_ttsAudio.onerror = function() { URL.revokeObjectURL(url); _ttsAudio = null; btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; };
btn.textContent = '⏹'; _ttsAudio.play();
})
.catch(function() { btn.textContent = '🔊'; btn.classList.remove('playing'); _ttsBtnActive = null; });
}
+13 -8
View File
@@ -145,7 +145,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
.wt-hdr{flex-wrap:wrap;padding:5px 10px;gap:5px;}
/* Sidebar → left overlay */
.wt-sb{
position:fixed;top:0;left:0;bottom:0;z-index:500;
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:78vw;max-width:280px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.4);
@@ -153,7 +153,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
.wt-sb.mob-open{transform:translateX(0);}
/* Chat → right overlay */
.wt-chat{
position:fixed;top:0;right:0;bottom:0;z-index:500;
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:360px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 24px rgba(0,0,0,.4);
@@ -162,9 +162,9 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
/* Hide resize handle */
.wt-resize{display:none;}
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid var(--line);height:52px;}
#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;}
.wt-layout{padding-bottom:52px;}
.wt-center{border:none;}
@@ -176,6 +176,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
#mob-tabbar{display:none !important;}
}
</style>
<script src="voice-utils.js"></script>
</head>
<body>
@@ -273,6 +274,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;fon
<div class="wt-msgs" id="chat-msgs"></div>
<div class="wt-input-bar">
<textarea class="wt-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="wt-send" id="send-btn" onclick="sendMessage()">전송</button>
</div>
</div>
@@ -538,7 +540,9 @@ function addMsg(role,html){
const msgs=document.getElementById('chat-msgs');
const div=document.createElement('div');div.className='wt-msg '+role;
const b=document.createElement('div');b.className='wt-bubble';b.innerHTML=html;
div.appendChild(b);msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
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;
}
@@ -575,13 +579,13 @@ function loadWindyUrl(url,ov){
document.getElementById('windy-frame').src=url;
}
function sendToChat(msg){if(window.innerWidth<=640)mobToggle('chat');addMsg('user',escHtml(msg));callChat(msg);}
function sendToChat(msg){if(window.innerWidth<=1024)mobToggle('chat');addMsg('user',escHtml(msg));callChat(msg);}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
if(window.innerWidth<=640)mobToggle('chat');
if(window.innerWidth<=1024)mobToggle('chat');
input.value='';autoResize(input);
addMsg('user',escHtml(msg));
await callChat(msg);
@@ -653,7 +657,7 @@ function mobCloseAll(){
document.getElementById('mobtab-map').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>640)return;
if(window.innerWidth>1024)return;
const sbOpen=document.querySelector('.wt-sb').classList.contains('mob-open');
const chatOpen=document.querySelector('.wt-chat').classList.contains('mob-open');
if(which==='sb'&&sbOpen){mobCloseAll();return;}
@@ -681,6 +685,7 @@ function mobToggle(which){
await Promise.all([initSessionId(),loadSkill(),loadModels()]);
await loadHistory();
addMsg('system','🌤️ 날씨 어시스턴트입니다. 지역 날씨, 예보, 기상 현상을 질문하세요.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>