Files
homeclaw/web-ui/detective-app.html
T
kimandClaude Sonnet 4.6 75a36cdc7d 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>
2026-06-27 19:16:59 +09:00

1892 lines
109 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔍 탐정 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
<link rel="stylesheet" href="styles.css">
<script>try{var _t=localStorage.getItem('cherryclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
<style>
html,body{height:100%;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
/* ── Header ── */
.dt-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.dt-hdr h1{font-size:13px;font-weight:700;margin:0;}
.dt-hdr-sp{flex:1;}
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
.hdr-btn:hover{border-color:#3b82f6;color:#3b82f6;}
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.25);color:#60a5fa;font-weight:700;}
/* ── Layout ── */
.dt-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.dt-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
.dt-sb-hdr{display:flex;align-items:center;padding:8px 10px 4px;gap:4px;flex-shrink:0;}
.dt-sb-title{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;flex:1;}
.dt-new-btn{background:#1e3a5f;border:none;border-radius:5px;padding:2px 8px;font-size:11px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;}
.dt-new-btn:hover{background:#1d4ed8;color:#fff;}
.dt-search-wrap{padding:0 8px 6px;flex-shrink:0;}
.dt-search{width:100%;box-sizing:border-box;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-search:focus{border-color:#3b82f6;}
.dt-search::placeholder{color:var(--muted);}
.dt-sort-bar{display:flex;align-items:center;padding:0 8px 4px;gap:4px;flex-shrink:0;}
.dt-sort-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 5px;font-size:10px;color:var(--muted);font-family:inherit;}
.dt-cases{flex:1;overflow-y:auto;padding:4px;}
.dt-cases::-webkit-scrollbar{width:3px;}
.dt-cases::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-case-item{padding:7px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:2px;transition:.12s;position:relative;}
.dt-case-item:hover{background:rgba(59,130,246,.05);border-color:rgba(59,130,246,.2);}
.dt-case-item.active{background:rgba(59,130,246,.1);border-color:rgba(59,130,246,.35);}
.dt-case-title{font-size:12px;font-weight:600;color:var(--text);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:18px;}
.dt-case-meta{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:4px;flex-wrap:wrap;}
.dt-case-del{position:absolute;top:6px;right:6px;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dt-case-item:hover .dt-case-del{opacity:.6;}
.dt-case-del:hover{opacity:1 !important;color:#ef4444;}
.dt-status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:2px;vertical-align:middle;flex-shrink:0;}
.s-active{background:#22c55e;}.s-done{background:#3b82f6;}.s-hold{background:#f59e0b;}
.dt-pri-dot{display:inline-block;width:5px;height:5px;border-radius:50%;vertical-align:middle;flex-shrink:0;}
.p-urgent{background:#ef4444;}.p-high{background:#f97316;}.p-normal{background:#6b7280;}.p-low{background:#374151;}
.dt-sb-divider{height:1px;background:var(--line);margin:6px 8px;}
.dt-quick-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:4px 10px;}
.dt-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:2px;}
.dt-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;line-height:1.4;}
.dt-qbtn:hover{border-color:#3b82f6;color:#3b82f6;background:rgba(59,130,246,.05);}
/* ── Center ── */
.dt-center{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
.dt-center-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:12px;padding:40px;}
.dt-center-empty .icon{font-size:48px;opacity:.25;}
.dt-center-empty p{font-size:12px;line-height:1.7;text-align:center;max-width:260px;}
.dt-case-hdr{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
.dt-case-name-input{flex:1;min-width:120px;background:transparent;border:none;font-size:14px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
.dt-case-name-input::placeholder{color:var(--muted);}
.dt-status-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--text);font-family:inherit;}
.dt-pri-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 5px;font-size:11px;color:var(--text);font-family:inherit;}
.dt-hdr-btns{display:flex;gap:5px;align-items:center;flex-shrink:0;}
.dt-ask-ai-btn{background:#1e3a5f;border:none;border-radius:6px;padding:4px 9px;font-size:11px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-ask-ai-btn:hover{background:#1d4ed8;color:#fff;}
.dt-report-btn{background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#a78bfa;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-report-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
.dt-complaint-btn{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#f87171;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-complaint-btn:hover{background:rgba(239,68,68,.25);color:#fff;}
.dt-submit-btn{background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.3);border-radius:6px;padding:4px 9px;font-size:11px;color:#34d399;cursor:pointer;font-family:inherit;font-weight:700;white-space:nowrap;transition:.15s;}
.dt-submit-btn:hover{background:rgba(16,185,129,.25);color:#fff;}
.dt-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
/* ── Tabs ── */
.dt-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;}
.dt-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:7px 12px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;}
.dt-tab-btn.active{color:#3b82f6;border-bottom-color:#3b82f6;}
.dt-tab-btn:hover:not(.active){color:var(--text);}
.dt-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden;}
.dt-tab-pane{flex:1;display:none;flex-direction:column;min-height:0;overflow:hidden;}
.dt-tab-pane.active{display:flex;}
/* ── Notes tab ── */
.dt-notes-area{flex:1;display:flex;flex-direction:column;padding:12px 14px;gap:6px;min-height:0;}
.dt-notes-label{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
.dt-notes-textarea{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;transition:.15s;}
.dt-notes-textarea:focus{border-color:#3b82f6;}
.dt-notes-textarea::placeholder{color:var(--muted);}
/* ── Checklist tab ── */
.dt-cl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dt-cl-add-bar{display:flex;gap:6px;flex-shrink:0;}
.dt-cl-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-cl-input:focus{border-color:#3b82f6;}
.dt-cl-input::placeholder{color:var(--muted);}
.dt-cl-add-btn{background:#1e3a5f;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dt-cl-add-btn:hover{background:#1d4ed8;color:#fff;}
.dt-cl-ai-btn{background:rgba(139,92,246,.15);border:1px solid rgba(139,92,246,.3);border-radius:7px;padding:7px 11px;font-size:11px;color:#a78bfa;cursor:pointer;font-family:inherit;font-weight:700;flex-shrink:0;transition:.15s;}
.dt-cl-ai-btn:hover{background:rgba(139,92,246,.3);color:#fff;}
.dt-cl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;}
.dt-cl-list::-webkit-scrollbar{width:3px;}
.dt-cl-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-cl-item{display:flex;align-items:flex-start;gap:8px;padding:7px 10px;border-radius:6px;border:1px solid var(--line);background:var(--panel);transition:.12s;group:1;}
.dt-cl-item:hover{border-color:rgba(59,130,246,.25);}
.dt-cl-item.done{opacity:.55;}
.dt-cl-cb{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--line);cursor:pointer;flex-shrink:0;margin-top:1px;display:flex;align-items:center;justify-content:center;font-size:10px;transition:.15s;background:transparent;}
.dt-cl-cb.checked{background:#22c55e;border-color:#22c55e;color:#fff;}
.dt-cl-text{flex:1;font-size:12px;line-height:1.5;color:var(--text);word-break:break-word;}
.dt-cl-item.done .dt-cl-text{text-decoration:line-through;color:var(--muted);}
.dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
.dt-cl-item:hover .dt-cl-del{opacity:.5;}
.dt-cl-del:hover{opacity:1 !important;color:#ef4444;}
.dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
.dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
.dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
.dt-cl-prog-text{font-size:10px;color:var(--muted);flex-shrink:0;}
.dt-cl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
/* ── Timeline tab ── */
.dt-tl-wrap{flex:1;display:flex;flex-direction:column;min-height:0;padding:12px 14px;gap:8px;}
.dt-tl-add-bar{display:flex;gap:6px;flex-shrink:0;align-items:center;}
.dt-tl-date{background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12px;color:var(--text);font-family:inherit;outline:none;width:130px;flex-shrink:0;transition:.15s;}
.dt-tl-date:focus{border-color:#3b82f6;}
.dt-tl-text-input{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:7px 11px;font-size:12px;color:var(--text);font-family:inherit;outline:none;transition:.15s;}
.dt-tl-text-input:focus{border-color:#3b82f6;}
.dt-tl-text-input::placeholder{color:var(--muted);}
.dt-tl-add-btn{background:#1e3a5f;border:none;border-radius:7px;padding:7px 13px;font-size:12px;color:#93c5fd;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dt-tl-add-btn:hover{background:#1d4ed8;color:#fff;}
.dt-tl-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:0;}
.dt-tl-list::-webkit-scrollbar{width:3px;}
.dt-tl-list::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-tl-item{display:flex;gap:10px;position:relative;padding-bottom:12px;}
.dt-tl-line{display:flex;flex-direction:column;align-items:center;flex-shrink:0;width:20px;}
.dt-tl-dot{width:10px;height:10px;border-radius:50%;background:#3b82f6;border:2px solid var(--bg);flex-shrink:0;margin-top:2px;}
.dt-tl-rod{flex:1;width:2px;background:var(--line);margin-top:2px;}
.dt-tl-item:last-child .dt-tl-rod{display:none;}
.dt-tl-body{flex:1;padding-top:0;}
.dt-tl-date-lbl{font-size:10px;font-weight:700;color:#60a5fa;margin-bottom:2px;}
.dt-tl-txt{font-size:12px;color:var(--text);line-height:1.5;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 10px;position:relative;}
.dt-tl-del{position:absolute;top:4px;right:6px;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);opacity:0;transition:.12s;padding:1px 3px;}
.dt-tl-txt:hover .dt-tl-del{opacity:.5;}
.dt-tl-del:hover{opacity:1 !important;color:#ef4444;}
.dt-tl-empty{text-align:center;color:var(--muted);font-size:12px;padding:30px 0;}
/* ── Resize ── */
.dt-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
.dt-resize:hover,.dt-resize.dragging{background:#3b82f6;}
/* ── Chat ── */
.dt-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
.dt-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
.dt-chat-label{font-size:10px;font-weight:700;color:#60a5fa;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
.dt-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
.dt-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
.dt-chat-clear:hover{color:#ef4444;}
.dt-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
.dt-msgs::-webkit-scrollbar{width:3px;}
.dt-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
.dt-msg{display:flex;flex-direction:column;}
.dt-msg.user{align-items:flex-end;}
.dt-msg.assistant{align-items:flex-start;}
.dt-msg.system{align-items:center;}
.dt-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
.dt-msg.user .dt-bubble{background:#1e3a5f;color:#bfdbfe;border-radius:10px 10px 3px 10px;}
.dt-msg.assistant .dt-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
.dt-msg.system .dt-bubble{background:transparent;color:var(--muted);font-size:11px;}
.dt-bubble p{margin:3px 0;}
.dt-bubble h3{font-size:13px;margin:6px 0 3px;}
.dt-bubble ul,.dt-bubble ol{padding-left:16px;margin:3px 0;}
.dt-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
.dt-bubble strong{font-weight:700;}
.dt-bubble table{border-collapse:collapse;font-size:11px;margin:6px 0;width:100%;}
.dt-bubble th,.dt-bubble td{border:1px solid var(--line);padding:3px 7px;text-align:left;}
.dt-bubble th{background:var(--panel);font-weight:700;}
.dt-bubble blockquote{border-left:3px solid var(--line);padding-left:10px;margin:4px 0;color:var(--muted);}
.dt-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
.dt-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
.dt-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
.dt-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:dtDot 1.2s ease-in-out infinite;}
.dt-dot:nth-child(2){animation-delay:.2s;}.dt-dot:nth-child(3){animation-delay:.4s;}
@keyframes dtDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
.dt-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
.dt-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
.dt-textarea:focus{border-color:#3b82f6;}
.dt-send{background:#1e3a5f;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
.dt-send:hover:not(:disabled){background:#1d4ed8;}
.dt-send:disabled{opacity:.5;cursor:not-allowed;}
/* ── Mobile ── */
@media(max-width:1024px){
.dt-resize{display:none;}
.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: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 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: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;}
}
@media(min-width:1025px){
#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>
<header class="dt-hdr">
<a href="/" class="hdr-btn">← 홈</a>
<h1>🔍 탐정 어시스턴트</h1>
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
<div class="dt-hdr-sp"></div>
<button class="hdr-btn" onclick="toggleTheme()">🌙 테마</button>
</header>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-cases" onclick="mobToggle('cases')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
<span>사건</span>
</button>
<button class="mob-tab" id="mobtab-home" onclick="mobToggle('home')" style="color:var(--brand,#3b82f6);border-top:2px solid var(--brand,#3b82f6)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
<span>노트</span>
</button>
<button class="mob-tab" id="mobtab-chat" onclick="mobToggle('chat')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" style="width:20px;height:20px;stroke-width:2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>AI</span>
</button>
</div>
<div class="dt-layout">
<!-- ── Sidebar ── -->
<aside class="dt-sb" id="dt-sb">
<div class="dt-sb-hdr">
<span class="dt-sb-title">사건 목록</span>
<button class="dt-new-btn" onclick="newCase()">+ 새 사건</button>
</div>
<div class="dt-search-wrap">
<input class="dt-search" id="case-search" type="text" placeholder="🔍 사건 검색..." oninput="renderCaseList()">
</div>
<div class="dt-sort-bar">
<select class="dt-sort-sel" id="case-sort" onchange="renderCaseList()">
<option value="date-desc">최신순</option>
<option value="date-asc">오래된순</option>
<option value="title">이름순</option>
<option value="status">상태순</option>
<option value="priority">우선순위순</option>
</select>
</div>
<div class="dt-cases" id="case-list"></div>
<div class="dt-sb-divider"></div>
<div class="dt-quick-sec">빠른 조사</div>
<div class="dt-quick">
<button class="dt-qbtn" onclick="sendQuick('인물 조사 방법론을 알려주세요. 공개 정보를 활용해 특정 인물의 이력, 소속, 활동을 파악하는 단계별 절차가 궁금합니다.')">👤 인물 조사 방법</button>
<button class="dt-qbtn" onclick="sendQuick('기업 실사(Due Diligence) 체크리스트를 알려주세요. 계약 전 상대 회사의 신뢰성과 재무 건전성을 확인하는 방법이 궁금합니다.')">🏢 기업 실사 체크리스트</button>
<button class="dt-qbtn" onclick="sendQuick('온라인 사기 피해를 입었습니다. 증거 수집 방법과 신고 절차를 알려주세요.')">🚨 온라인 사기 대응</button>
<button class="dt-qbtn" onclick="sendQuick('SNS·인터넷에서 허위 정보나 루머가 퍼지고 있습니다. 출처 추적과 대응 방법을 알려주세요.')">📱 허위정보 추적</button>
<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>
<div class="dt-quick-sec">⚖️ 법률 질문</div>
<div class="dt-quick">
<button class="dt-qbtn" onclick="sendQuick('사기죄(형법 제347조) 성립 요건을 알려주세요. 판례 기준으로 기망행위·재산상 이익·인과관계 각각 어떻게 판단하는지 설명해 주세요. 관련 대법원 판례도 찾아주세요.')">📌 사기죄 성립 요건</button>
<button class="dt-qbtn" onclick="sendQuick('민사소송과 형사고소의 차이점을 알려주세요. 같은 피해에 대해 동시에 진행할 수 있는지, 어떤 경우에 어느 쪽이 유리한지도 설명해 주세요.')">⚖️ 민사 vs 형사 선택</button>
<button class="dt-qbtn" onclick="sendQuick('내용증명 작성 방법과 법적 효력을 알려주세요. 어떤 상황에서 보내야 하고, 보낸 후 상대방이 무시하면 어떻게 대응해야 하는지도 알려주세요.')">📮 내용증명 작성·효력</button>
<button class="dt-qbtn" onclick="sendQuick('명예훼손죄와 모욕죄의 차이를 알려주세요. 온라인(SNS, 카카오톡 단체방 등)에서 발생한 경우 성립 요건과 처벌 수위, 증거 수집 방법도 알려주세요. 관련 판례를 찾아주세요.')">🗣 명예훼손·모욕죄</button>
<button class="dt-qbtn" onclick="sendQuick('계약 해지·위약금 관련 법률을 알려주세요. 상대방이 계약을 이행하지 않을 때 해지 통보 방법, 위약금 청구 절차, 관련 민법 조항과 판례를 설명해 주세요.')">📝 계약 해지·위약금</button>
<button class="dt-qbtn" onclick="sendQuick('임금 체불 피해를 받았습니다. 근로기준법상 임금 체불의 법적 정의, 고소 절차, 체당금 신청 방법, 사업주 처벌 수위를 알려주세요. 관련 법령도 찾아주세요.')">💼 임금 체불 대응</button>
<button class="dt-qbtn" onclick="sendQuick('현재 사건에 적용 가능한 법령과 판례를 조사해서 법적 대응 방향을 제시해주세요.',true)">🔍 현재 사건 법률 검토</button>
</div>
</aside>
<!-- ── Center: Case notepad ── -->
<section class="dt-center" id="dt-center">
<div class="dt-center-empty" id="center-empty">
<div class="icon">🔍</div>
<p>좌측에서 사건을 선택하거나<br><strong>새 사건</strong>을 만들어 단서와 메모를<br>기록하세요.</p>
</div>
<div id="center-case" style="display:none;flex:1;flex-direction:column;min-height:0;">
<!-- Case header -->
<div class="dt-case-hdr">
<input class="dt-case-name-input" id="case-name-input" placeholder="사건명 입력..." oninput="onCaseNameChange()" onblur="saveCases()">
<select class="dt-status-sel" id="case-status-sel" onchange="onStatusChange()">
<option value="조사중">🟢 조사중</option>
<option value="완료">🔵 완료</option>
<option value="보류">🟡 보류</option>
</select>
<select class="dt-pri-sel" id="case-pri-sel" onchange="onPriChange()">
<option value="normal">⚪ 보통</option>
<option value="urgent">🔴 긴급</option>
<option value="high">🟠 높음</option>
<option value="low">⬛ 낮음</option>
</select>
<div class="dt-hdr-btns">
<button class="dt-ask-ai-btn" onclick="askAiAboutCase()">💬 분석</button>
<button class="dt-report-btn" onclick="generateReport()">📄 보고서</button>
<button class="dt-complaint-btn" onclick="generateComplaint()">⚖️ 고소장</button>
<button class="dt-submit-btn" onclick="guideSubmission()">📮 접수 안내</button>
<span class="dt-save-indicator" id="save-indicator"></span>
</div>
</div>
<!-- Tabs -->
<div class="dt-tabs">
<button class="dt-tab-btn active" onclick="switchTab('notes')" id="tab-notes">📝 메모</button>
<button class="dt-tab-btn" onclick="switchTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
<button class="dt-tab-btn" onclick="switchTab('timeline')" id="tab-timeline">📅 타임라인</button>
</div>
<div class="dt-tab-content">
<!-- Notes pane -->
<div class="dt-tab-pane active" id="pane-notes">
<div class="dt-notes-area">
<span class="dt-notes-label">단서 · 증거 · 메모</span>
<textarea class="dt-notes-textarea" id="case-notes" placeholder="조사 대상, 확인된 사실, 의심 정황, 수집한 단서 등을 자유롭게 기록하세요.
예시:
- 조사 대상: 홍길동 (대표이사, ○○ 주식회사)
- 확인된 사실: 2024년 법인 설립, 주소지 ○○동
- 의심 정황: SNS에서 확인되지 않은 수익 주장
- 수집 단서: 사업자등록증 번호, 전화번호 ○○○" oninput="onNotesChange()" onblur="saveCases()"></textarea>
</div>
</div>
<!-- Checklist pane -->
<div class="dt-tab-pane" id="pane-checklist">
<div class="dt-cl-wrap">
<div class="dt-cl-add-bar">
<input class="dt-cl-input" id="cl-input" type="text" placeholder="조사 항목 추가..." onkeydown="if(event.key==='Enter')addCheckItem()">
<button class="dt-cl-add-btn" onclick="addCheckItem()">추가</button>
<button class="dt-cl-ai-btn" onclick="generateChecklist()" title="AI로 체크리스트 자동 생성">✨ AI</button>
</div>
<div class="dt-cl-progress" id="cl-progress" style="display:none">
<div class="dt-cl-prog-bar"><div class="dt-cl-prog-fill" id="cl-prog-fill"></div></div>
<span class="dt-cl-prog-text" id="cl-prog-text">0/0</span>
</div>
<div class="dt-cl-list" id="cl-list"></div>
</div>
</div>
<!-- Timeline pane -->
<div class="dt-tab-pane" id="pane-timeline">
<div class="dt-tl-wrap">
<div class="dt-tl-add-bar">
<input class="dt-tl-date" id="tl-date" type="date">
<input class="dt-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
<button class="dt-tl-add-btn" onclick="addTimelineEvent()">추가</button>
</div>
<div class="dt-tl-list" id="tl-list"></div>
</div>
</div>
</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 ── -->
<div class="dt-resize" id="resize-handle"></div>
<!-- ── Chat ── -->
<div class="dt-chat" id="chat-panel">
<div class="dt-chat-hdr">
<span class="dt-chat-label">🔍 탐정 AI</span>
<select class="dt-model-sel" id="model-sel" onchange="saveModel()">
<option value="">기본 모델</option>
</select>
<button class="dt-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
</div>
<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>
</div>
<script>
// ── Auth ──────────────────────────────────────────────────────────────────────
const TOKEN_KEY='smallclaw_token';
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
async function checkAuth(){
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('cherryclaw_theme',n);}catch{}}
// ── Mobile ────────────────────────────────────────────────────────────────────
function mobCloseAll(){
document.getElementById('dt-sb').classList.remove('mob-open');
document.getElementById('chat-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['cases','chat'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
document.getElementById('mobtab-home').style.cssText='color:var(--brand,#3b82f6);border-top:2px solid var(--brand,#3b82f6)';
}
function mobToggle(which){
const sb=document.getElementById('dt-sb');
const cp=document.getElementById('chat-panel');
const bd=document.getElementById('mob-backdrop');
if(which==='home'){mobCloseAll();return;}
const sbOpen=sb.classList.contains('mob-open');
const cpOpen=cp.classList.contains('mob-open');
mobCloseAll();
if(which==='cases'&&!sbOpen){sb.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-cases').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
if(which==='chat'&&!cpOpen){cp.classList.add('mob-open');bd.classList.add('show');document.getElementById('mobtab-chat').classList.add('active');document.getElementById('mobtab-home').style.cssText='';}
}
// ── Resize ────────────────────────────────────────────────────────────────────
(function(){
const CHAT_W='dt_chat_width';
const handle=document.getElementById('resize-handle');
const panel=document.getElementById('chat-panel');
let dragging=false,startX=0,startW=0;
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
})();
// ── Tabs ──────────────────────────────────────────────────────────────────────
let activeTab='notes';
function switchTab(tab){
activeTab=tab;
['notes','checklist','timeline'].forEach(t=>{
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
});
if(tab==='checklist')renderChecklist();
if(tab==='timeline')renderTimeline();
}
// ── Cases ─────────────────────────────────────────────────────────────────────
let cases=[];
let currentCaseId=null;
let saveTimer=null;
function genId(){return 'case_'+Math.random().toString(36).slice(2,10);}
function normalizeCase(c){
if(!c.checklist)c.checklist=[];
if(!c.timeline)c.timeline=[];
if(!c.priority)c.priority='normal';
return c;
}
async function loadCases(){
try{
const r=await fetch('/api/detective/cases',{headers:authH()});
if(r.ok){const d=await r.json();if(Array.isArray(d.cases))cases=d.cases.map(normalizeCase);}
}catch{}
renderCaseList();
}
async function saveCases(){
updateCurrentCaseFromDom();
try{
await fetch('/api/detective/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
showSaveIndicator('저장됨');
}catch{showSaveIndicator('저장 실패');}
}
function showSaveIndicator(msg){
const el=document.getElementById('save-indicator');
if(!el)return;
el.textContent=msg;
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{el.textContent='';},2000);
}
function updateCurrentCaseFromDom(){
if(!currentCaseId)return;
const c=cases.find(x=>x.id===currentCaseId);
if(!c)return;
c.title=document.getElementById('case-name-input').value||'제목 없음';
c.status=document.getElementById('case-status-sel').value;
c.priority=document.getElementById('case-pri-sel').value;
c.notes=document.getElementById('case-notes').value;
}
const PRI_ORDER={urgent:0,high:1,normal:2,low:3};
const STATUS_ORDER={'조사중':0,'보류':1,'완료':2};
function getSortedCases(){
const q=document.getElementById('case-search')?.value?.toLowerCase()||'';
const sort=document.getElementById('case-sort')?.value||'date-desc';
let list=[...cases];
if(q)list=list.filter(c=>(c.title||'').toLowerCase().includes(q)||(c.notes||'').toLowerCase().includes(q));
list.sort((a,b)=>{
if(sort==='date-desc')return new Date(b.createdAt||0)-new Date(a.createdAt||0);
if(sort==='date-asc')return new Date(a.createdAt||0)-new Date(b.createdAt||0);
if(sort==='title')return (a.title||'').localeCompare(b.title||'','ko');
if(sort==='status')return (STATUS_ORDER[a.status]??9)-(STATUS_ORDER[b.status]??9);
if(sort==='priority')return (PRI_ORDER[a.priority]??9)-(PRI_ORDER[b.priority]??9);
return 0;
});
return list;
}
function renderCaseList(){
const list=document.getElementById('case-list');
const sorted=getSortedCases();
if(!sorted.length){
list.innerHTML='<div style="padding:10px;font-size:11px;color:var(--muted);text-align:center">사건이 없습니다.</div>';
return;
}
list.innerHTML=sorted.map(c=>{
const dotCls=c.status==='완료'?'s-done':c.status==='보류'?'s-hold':'s-active';
const priCls='p-'+(c.priority||'normal');
const priLabel={urgent:'긴급',high:'높음',normal:'',low:'낮음'}[c.priority||'normal'];
const isActive=c.id===currentCaseId;
const date=c.createdAt?new Date(c.createdAt).toLocaleDateString('ko',{month:'short',day:'numeric'}):'';
const clDone=(c.checklist||[]).filter(x=>x.done).length;
const clTotal=(c.checklist||[]).length;
return `<div class="dt-case-item${isActive?' active':''}" onclick="selectCase('${c.id}')">
<div class="dt-case-title">${escHtml(c.title||'제목 없음')}</div>
<div class="dt-case-meta">
<span class="dt-status-dot ${dotCls}"></span>${escHtml(c.status||'조사중')}
${priLabel?`<span class="dt-pri-dot ${priCls}"></span>${priLabel}`:''}
${date?'· '+date:''}
${clTotal?`<span style="color:var(--muted)">✅${clDone}/${clTotal}</span>`:''}
</div>
<button class="dt-case-del" onclick="event.stopPropagation();deleteCase('${c.id}')" title="삭제">✕</button>
</div>`;
}).join('');
}
function selectCase(id){
updateCurrentCaseFromDom();
currentCaseId=id;
const c=cases.find(x=>x.id===id);
if(!c)return;
document.getElementById('case-name-input').value=c.title||'';
document.getElementById('case-status-sel').value=c.status||'조사중';
document.getElementById('case-pri-sel').value=c.priority||'normal';
document.getElementById('case-notes').value=c.notes||'';
document.getElementById('center-empty').style.display='none';
const cc=document.getElementById('center-case');
cc.style.display='flex';cc.style.flexDirection='column';cc.style.flex='1';cc.style.minHeight='0';
renderCaseList();
if(activeTab==='checklist')renderChecklist();
if(activeTab==='timeline')renderTimeline();
if(window.innerWidth<=1024)mobCloseAll();
}
function newCase(){
const c=normalizeCase({id:genId(),title:'새 사건',status:'조사중',notes:'',createdAt:new Date().toISOString()});
cases.unshift(c);
selectCase(c.id);
saveCases();
setTimeout(()=>{const el=document.getElementById('case-name-input');if(el){el.focus();el.select();}},50);
}
async function deleteCase(id){
if(!confirm('이 사건을 삭제할까요?'))return;
cases=cases.filter(c=>c.id!==id);
if(currentCaseId===id){
currentCaseId=null;
document.getElementById('center-empty').style.display='';
document.getElementById('center-case').style.display='none';
}
await saveCases();
renderCaseList();
}
function onCaseNameChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.title=document.getElementById('case-name-input').value;
renderCaseList();
}
function onStatusChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.status=document.getElementById('case-status-sel').value;
renderCaseList();saveCases();
}
function onPriChange(){
const c=cases.find(x=>x.id===currentCaseId);
if(c)c.priority=document.getElementById('case-pri-sel').value;
renderCaseList();saveCases();
}
function onNotesChange(){/* auto-save on blur */}
// ── Checklist ──────────────────────────────────────────────────────────────────
function currentCase(){return cases.find(x=>x.id===currentCaseId);}
function renderChecklist(){
const c=currentCase();
const list=document.getElementById('cl-list');
const prog=document.getElementById('cl-progress');
if(!c){list.innerHTML='';prog.style.display='none';return;}
const items=c.checklist||[];
const done=items.filter(x=>x.done).length;
if(items.length){
prog.style.display='flex';
document.getElementById('cl-prog-fill').style.width=(done/items.length*100)+'%';
document.getElementById('cl-prog-text').textContent=done+'/'+items.length;
}else{prog.style.display='none';}
if(!items.length){
list.innerHTML='<div class="dt-cl-empty">조사 항목을 추가하거나<br>AI 자동 생성을 이용하세요.</div>';
return;
}
list.innerHTML=items.map(item=>`
<div class="dt-cl-item${item.done?' done':''}" id="cli-${item.id}">
<div class="dt-cl-cb${item.done?' checked':''}" onclick="toggleCheck('${item.id}')">${item.done?'✓':''}</div>
<div class="dt-cl-text">${escHtml(item.text)}</div>
<button class="dt-cl-del" onclick="deleteCheck('${item.id}')">✕</button>
</div>
`).join('');
}
function addCheckItem(){
const inp=document.getElementById('cl-input');
const text=inp.value.trim();
if(!text||!currentCaseId)return;
const c=currentCase();
if(!c)return;
if(!c.checklist)c.checklist=[];
c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false});
inp.value='';
renderChecklist();renderCaseList();saveCases();
}
function toggleCheck(id){
const c=currentCase();if(!c)return;
const item=(c.checklist||[]).find(x=>x.id===id);
if(item)item.done=!item.done;
renderChecklist();renderCaseList();saveCases();
}
function deleteCheck(id){
const c=currentCase();if(!c)return;
c.checklist=(c.checklist||[]).filter(x=>x.id!==id);
renderChecklist();renderCaseList();saveCases();
}
async function generateChecklist(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes){alert('먼저 메모 탭에 사건 정보를 입력하세요.');return;}
const title=c?.title||'현재 사건';
const prompt=`다음 사건의 조사 체크리스트를 만들어주세요. 각 항목을 "- " 로 시작하는 목록 형식으로 작성해 주세요. (최대 15개)
사건명: ${title}
사건 내용:
${notes||'(내용 없음)'}
체크리스트 항목만 줄바꿈으로 나열해주세요. 설명 없이 항목만 출력.`;
addMsg('user',escHtml('✨ AI 체크리스트 자동 생성 중...'));
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);
if(items.length){
if(!c.checklist)c.checklist=[];
items.forEach(text=>c.checklist.push({id:'cl_'+Math.random().toString(36).slice(2,8),text,done:false}));
renderChecklist();renderCaseList();saveCases();
addMsg('system',`✅ ${items.length}개 항목이 체크리스트에 추가되었습니다.`);
}
}
}
// ── Timeline ──────────────────────────────────────────────────────────────────
function renderTimeline(){
const c=currentCase();
const list=document.getElementById('tl-list');
if(!c){list.innerHTML='';return;}
const events=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date));
if(!events.length){
list.innerHTML='<div class="dt-tl-empty">날짜와 이벤트를 추가하여<br>사건 타임라인을 구성하세요.</div>';
return;
}
list.innerHTML=events.map(ev=>`
<div class="dt-tl-item">
<div class="dt-tl-line"><div class="dt-tl-dot"></div><div class="dt-tl-rod"></div></div>
<div class="dt-tl-body">
<div class="dt-tl-date-lbl">${escHtml(ev.date)}</div>
<div class="dt-tl-txt">
${escHtml(ev.text)}
<button class="dt-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
</div>
</div>
</div>
`).join('');
}
function addTimelineEvent(){
const date=document.getElementById('tl-date').value;
const text=document.getElementById('tl-input').value.trim();
if(!date||!text||!currentCaseId){if(!date)alert('날짜를 선택하세요.');return;}
const c=currentCase();if(!c)return;
if(!c.timeline)c.timeline=[];
c.timeline.push({id:'tl_'+Math.random().toString(36).slice(2,8),date,text});
document.getElementById('tl-input').value='';
renderTimeline();saveCases();
}
function deleteEvent(id){
const c=currentCase();if(!c)return;
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
renderTimeline();saveCases();
}
// ── AI Actions ────────────────────────────────────────────────────────────────
function askAiAboutCase(){
const c=currentCase();
const notes=document.getElementById('case-notes')?.value||'';
if(!c&&!notes)return;
const title=c?.title||'현재 사건';
const msg=`다음 사건을 분석하고 조사 방향을 제안해주세요.\n\n**사건명:** ${title}\n**상태:** ${c?.status||'조사중'}\n\n**수집된 단서 및 메모:**\n${notes||'(없음)'}`;
addMsg('user',escHtml('사건 분석 요청: '+title));
callChat(msg);
if(window.innerWidth<=1024)mobToggle('chat');
}
async function generateReport(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const clItems=(c.checklist||[]).map(x=>`[${x.done?'완료':'미완'}] ${x.text}`).join('\n')||'(없음)';
const tlItems=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date)).map(x=>`${x.date}: ${x.text}`).join('\n')||'(없음)';
const prompt=`다음 사건 정보를 바탕으로 구조화된 조사 보고서를 작성해주세요.
**사건명:** ${c.title}
**상태:** ${c.status} **우선순위:** ${c.priority}
**수집된 단서/메모:**
${notes||'(없음)'}
**조사 체크리스트:**
${clItems}
**타임라인:**
${tlItems}
위 정보를 분석하여 공식 조사 보고서 형식으로 작성해주세요. 확인된 사실, 미확인 사항, 의심 정황, 종합 의견, 권고 사항을 포함하세요.`;
addMsg('user',escHtml('📄 보고서 생성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=1024)mobToggle('chat');
}
async function generateComplaint(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const tlItems=[...(c.timeline||[])].sort((a,b)=>a.date.localeCompare(b.date)).map(x=>`${x.date}: ${x.text}`).join('\n')||'(없음)';
const prompt=`다음 사건 정보를 바탕으로 한국 형사소송법에 맞는 **고소장**을 작성해주세요.
**사건명:** ${c.title}
**수집된 단서/메모:**
${notes||'(없음)'}
**타임라인:**
${tlItems}
---
아래 형식으로 완성된 고소장을 작성해주세요. 알 수 없는 정보는 [ ] 로 표시하세요.
\`\`\`
고 소 장
고소인
성 명:
주 소:
연 락 처:
피고소인
성 명:
주 소: (알 수 없는 경우 [불상])
연 락 처:
고소 취지
피고소인을 [죄명]으로 고소하오니 엄중히 처벌하여 주시기 바랍니다.
범죄사실
[날짜·장소·경위·행위를 육하원칙에 따라 서술]
고소 이유
1. [피해 사실 및 증거]
2. [법적 근거 — 해당 형법 조항]
3. [피고소인의 책임]
증거자료
1. [증거1]
2. [증거2]
결론
이 고소장에 기재된 범죄사실에 대하여 피고소인을 철저히 수사하여 엄중히 처벌하여 주시기 바랍니다.
20 년 월 일
고소인: (인)
○○경찰서장(또는 ○○지방검찰청) 귀중
\`\`\`
작성 후 추가로 보완이 필요한 정보(피해금액, 증거자료 목록, 고소인 인적사항 등)를 정리해서 안내해 주세요.`;
addMsg('user',escHtml('⚖️ 고소장 작성 요청: '+c.title));
callChat(prompt);
if(window.innerWidth<=1024)mobToggle('chat');
}
async function guideSubmission(){
const c=currentCase();
if(!c){alert('사건을 먼저 선택하세요.');return;}
const notes=document.getElementById('case-notes')?.value||'';
const address=prompt('피해 발생 지역을 입력하세요 (시·구·동 수준, 없으면 취소).\n예: 서울시 강남구 역삼동')||'';
const msg=`다음 사건 정보를 분석하여 고소장·신고서 접수 기관을 안내해주세요.
**사건명:** ${c.title}
**사건 내용:**
${notes||'(없음)'}${address?`\n**피해 발생 지역:** ${address}`:''}
아래 순서로 답변해주세요.
### 1. 범죄 유형 분류
사건 내용을 분석해 범죄 유형과 해당 형법·특별법 조항을 제시해주세요.
### 2. 신고·접수 기관 목록
범죄 유형에 맞는 신고 기관을 우선순위 순으로 안내해주세요.
각 기관마다 기관명, 직접 신고 URL, 전화번호, 접수 방법(온라인/방문/우편)을 포함해주세요.
참고 포털 (해당하는 것만):
- 경찰청 사이버수사국 ECRM (사이버범죄): https://ecrm.police.go.kr
- 금융감독원 불법금융신고센터 (금융사기·보이스피싱): https://www.fss.or.kr — 전화 1332
- 보이스피싱 지킴이 (보이스피싱): https://phishing-keeper.fss.or.kr
- 검찰청 형사사법포털 (일반 형사): https://www.spo.go.kr
- 고용노동부 민원마당 (임금 미지급·노동): https://minwon.moel.go.kr — 전화 1350
- 공정거래위원회 신고센터 (불공정거래): https://www.ftc.go.kr
- 방송통신심의위원회 (명예훼손·불법정보): https://www.kocsc.or.kr
- 경찰청 112 / 민원포털 182
### 3. 관할 경찰서·검찰청${address?`
web_search를 사용해 "${address} 관할 경찰서"와 "${address} 관할 검찰청"을 검색하고 기관명·주소·전화번호를 알려주세요.`:`
피해 발생 지역이 입력되지 않아 검색을 생략합니다. 지역을 알면 관할 기관을 찾아드릴 수 있습니다.`}
### 4. 접수 절차 요약
고소장 제출 → 접수 → 수사 개시까지 단계별 절차를 간략히 안내해주세요.`;
addMsg('user',escHtml('📮 접수 기관 안내: '+c.title+(address?' ('+address+')':'')));
callChat(msg);
if(window.innerWidth<=1024)mobToggle('chat');
}
// ── Model ─────────────────────────────────────────────────────────────────────
const MODEL_KEY='dt_model';
let selectedModel=localStorage.getItem(MODEL_KEY)||'';
async function loadModels(){
const sel=document.getElementById('model-sel');
try{
const[sr,or]=await Promise.all([fetch('/api/settings/model',{headers:authH()}),fetch('/api/ollama/models',{headers:authH()})]);
const setting=sr.ok?await sr.json():{};const od=or.ok?await or.json():{};
const primary=setting.primary||'기본';const models=(od.models||[]).map(m=>m.name);
sel.innerHTML=`<option value="">기본 (${primary})</option>`;
if(selectedModel&&!models.includes(selectedModel))models.unshift(selectedModel);
for(const m of models){const o=document.createElement('option');o.value=m;o.textContent=m;if(m===selectedModel)o.selected=true;sel.appendChild(o);}
if(!selectedModel)sel.value='';
}catch{}
}
function saveModel(){selectedModel=document.getElementById('model-sel').value;try{localStorage.setItem(MODEL_KEY,selectedModel);}catch{}}
// ── Skill ─────────────────────────────────────────────────────────────────────
const APP_KEY='dt';
let skillContext='';
let _sessionId=null;
function getSessionId(){return _sessionId||'dt_main';}
async function initSessionId(){
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
try{
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length)return;
const recent=msgs.slice(-30);
const el=document.getElementById('chat-msgs');
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
for(const m of recent){
if(m.role==='user')addMsg('user',escHtml(m.content||''));
else addMsg('assistant',renderMd(m.content||''));
}
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
el.scrollTop=el.scrollHeight;
}catch{}
}
const SKILL_CONTENT=`---
name: Detective
description: 공개 정보 기반 인물·기업·사건 조사 전문 어시스턴트. 사실 우선, 가설 수립, 증거 수집, 구조적 보고.
emoji: "🔍"
version: 1.1.0
triggers: [조사, 탐문, 인물, 기업, 사건, 단서, 증거, 추적, 사기, 실사, 배경조사, 확인, 검증]
---
당신은 공개 정보(OSINT) 기반의 전문 조사 어시스턴트입니다. 모든 조사는 합법적으로 접근 가능한 공개 정보만 활용하며, 아래 방법론을 따릅니다.
---
## 조사 방법론 — 항상 이 순서로 접근
### 1단계 — 사실 확인 (Facts First)
- 의뢰인이 제공한 정보를 그대로 정리 (추론 없이)
- 확인된 사실 vs 미확인 주장 명확히 구분
- 핵심 식별자 추출: 이름, 법인번호, 연락처, 주소, SNS 계정 등
### 2단계 — 가설 수립 (Hypotheses)
- 가능한 시나리오 2~3개 제시
- 각 가설의 확률과 근거 설명
- 우선 조사 방향 결정
### 3단계 — 증거 수집 (Evidence Gathering)
- \`web_search\`로 공개 정보 탐색
- 공신력 있는 출처 우선: 법인 등기, 공시 자료, 뉴스, SNS 공개 게시물
- 각 정보의 출처·날짜 반드시 명시
### 4단계 — 추론 (Reasoning)
- 수집된 증거 기반으로만 추론
- 근거 없는 추측 금지
- 불확실한 부분 명시
### 5단계 — 보고 (Report)
구조화된 형식으로 보고:
\`\`\`
## 조사 보고서
**대상:** [이름/기관]
**요청 사항:** [조사 목적]
**조사 일자:** [날짜]
### 확인된 사실
- ...
### 미확인 / 추가 조사 필요
- ...
### 의심 정황
- ...
### 종합 의견
[리스크 수준: 낮음/보통/높음]
[근거 요약]
### 권고 사항
- ...
\`\`\`
---
## 사용 가능한 도구
| 도구 | 활용 상황 |
|---|---|
| \`web_search\` | 인물·기업 공개 정보, 뉴스, SNS, 사업자 정보 검색 |
| \`python_eval\` | 데이터 분석, 패턴 추출, 타임라인 정리 |
| \`korean_law_search\` | 한국 법령·판례 검색 (target="law" 또는 "prec") |
| \`korean_law_fetch\` | 법령 전문·판례 전문 조회 |
### 우선 참조 사이트 (web_search)
| 조사 대상 | site: 연산자 | 설명 |
|---|---|---|
| 한국 법인 | \`site:bizno.net\` | 사업자등록번호 조회 |
| 한국 법인 공시 | \`site:dart.fss.or.kr\` | 금융감독원 DART |
| 법원 경매·파산 | \`site:courtauction.go.kr\` | 법원 경매 정보 |
| 부동산 | \`site:iros.go.kr\` | 인터넷 등기소 |
| 뉴스 | \`site:news.naver.com\` | 네이버 뉴스 |
| 해외 기업 | \`site:opencorporates.com\` | 글로벌 법인 검색 |
| 한국 법령 원문 | \`site:law.go.kr\` | 국가법령정보센터 |
| 대법원 판례 | \`site:glaw.scourt.go.kr\` | 대법원 종합법률정보 |
---
## 법률 검토가 필요한 상황
조사 중 아래 상황이 발생하면 \`korean_law_search\`로 관련 법령·판례를 직접 조회하세요.
| 상황 | 조회할 법령·판례 |
|---|---|
| 사기·편취 의심 | 형법 제347조(사기죄), 관련 대법원 판례 |
| 계약 위반·미이행 | 민법 채무불이행(제390조~), 손해배상 판례 |
| 개인정보 무단 수집 | 개인정보 보호법, 정보통신망법 |
| 불법 채권추심 | 채권의 공정한 추심에 관한 법률 |
| 명예훼손·협박 | 형법 제307조(명예훼손), 제283조(협박죄) |
| 부동산 사기 | 부동산 실권리자명의 등기에 관한 법률 |
| 임금 미지급 | 근로기준법 제43조, 제109조(벌칙) |
---
## 응답 원칙
1. **사실과 추론 명확히 구분**: "확인됨" vs "추정됨" vs "미확인"
2. **출처 명시**: 모든 정보에 URL 또는 출처 명시
3. **리스크 판단**: 의뢰인이 취해야 할 행동 권고
4. **법적 근거 제시**: 관련 법령·판례를 조회해 법적 판단 근거 함께 제공
5. **전문가 연계 고지**: 형사고소·민사소송 등 법적 절차가 필요한 경우 변호사 상담 권고
6. **불법 조사 금지**: 해킹, 불법 감청, 개인정보 무단 수집 요청 거부
`;
function loadSkill(){
skillContext=SKILL_CONTENT;
document.getElementById('skill-badge').textContent='🔍 조사관 활성';
}
// ── Chat ──────────────────────────────────────────────────────────────────────
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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);
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=1024)mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg,withCase){
if(withCase&&currentCaseId){askAiAboutCase();return;}
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='...';
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
}
async function callChatRaw(message){
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:'dt_tmp_'+Math.random().toString(36).slice(2,8),useTools:false,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
return textBuf;
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
return null;
}
}
async function clearChat(){
if(!confirm('대화를 지울까요?'))return;
document.getElementById('chat-msgs').innerHTML='';
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
addMsg('system','새 대화가 시작되었습니다. 조사 요청을 입력하세요.');
}
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
const ok=await checkAuth();if(!ok)return;
// Set today as default timeline date
document.getElementById('tl-date').value=new Date().toISOString().slice(0,10);
await initSessionId();
await loadHistory();
loadSkill();
await Promise.all([loadCases(),loadModels()]);
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
initAppVoice(sendMessage);
document.getElementById('chat-input').focus();
})();
</script>
</body>
</html>