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:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;}
|
||||
|
||||
@@ -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
@@ -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&¤tCaseId){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>
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user