파일별 다운로드(download 속성), 폴더 전체 zip 다운로드(archiver 신규 의존성 + /api/detective/files/download-folder 라우트) 추가. 사이드바에 법률 서식(대한법률구조공단)·법원 소송 서식(전자소송포털)·대법원 판례 검색(사법정보공개포털)·경찰청 링크 블록 신설. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2694 lines
155 KiB
HTML
2694 lines
155 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>🔍 탐정 어시스턴트</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
||
<link rel="stylesheet" href="../css/styles.css">
|
||
<script>try{var _t=localStorage.getItem('homeclaw_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-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;}
|
||
.dt-ev-photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;}
|
||
.dt-ev-photo{position:relative;border-radius:7px;overflow:hidden;border:1px solid var(--line);aspect-ratio:1;background:var(--panel);}
|
||
.dt-ev-photo img{width:100%;height:100%;object-fit:cover;display:block;}
|
||
.dt-ev-photo .dt-ev-photo-del{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.55);border:none;color:#fff;border-radius:5px;font-size:10px;padding:1px 4px;cursor:pointer;opacity:0;transition:.12s;}
|
||
.dt-ev-photo:hover .dt-ev-photo-del{opacity:1;}
|
||
.dt-ev-folder-header{display:flex;align-items:center;justify-content:space-between;padding:6px 4px 4px;margin-top:10px;border-top:1px solid var(--line);font-size:12px;font-weight:700;color:var(--text);cursor:pointer;user-select:none;}
|
||
.dt-ev-folder-header .dt-cl-del{opacity:.6;font-size:10px;}
|
||
.dt-ev-folder-header .dt-cl-del:hover{opacity:1;}
|
||
.dt-ev-chevron{display:inline-block;width:11px;font-size:9px;color:var(--muted);}
|
||
.dt-ev-folder-body{padding-left:6px;}
|
||
.dt-ev-date-title{font-size:10px;font-weight:700;color:var(--muted);margin:6px 0 2px 2px;}
|
||
.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="../js/voice/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>
|
||
|
||
<div class="dt-sb-divider"></div>
|
||
<div class="dt-quick-sec">🔗 관련 사이트</div>
|
||
<div class="dt-quick">
|
||
<a class="dt-qbtn" href="https://www.klac.or.kr/legalinfo/legalFrm.do" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="고소장, 내용증명 등 실무 법률서식 (대한법률구조공단)">📄 법률 서식 다운로드 (대한법률구조공단)</a>
|
||
<a class="dt-qbtn" href="https://ecfs.scourt.go.kr/psp/index.on" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="소장, 답변서 등 민사소송 서식 (전자소송포털)">🏛 법원 (전자소송포털)</a>
|
||
<a class="dt-qbtn" href="https://portal.scourt.go.kr/pgp/index.on?m=PGP1011M01&l=N&c=900" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="대법원 판례 검색 (사법정보공개포털)">⚖️ 대법원 판례 검색</a>
|
||
<a class="dt-qbtn" href="https://www.police.go.kr" target="_blank" rel="noopener" style="text-decoration:none;display:block" title="경찰청 홈페이지">👮 경찰청</a>
|
||
</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>
|
||
<button class="dt-tab-btn" onclick="switchTab('files')" id="tab-files">📎 증거자료</button>
|
||
<button class="dt-tab-btn" onclick="switchTab('docs')" id="tab-docs">📋 제출서류</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()" onkeydown="handleNotesTab(event)"></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 id="tl-suggestions"></div>
|
||
<div class="dt-tl-list" id="tl-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Files pane -->
|
||
<div class="dt-tab-pane" id="pane-files">
|
||
<div class="dt-cl-wrap">
|
||
<div class="dt-cl-add-bar">
|
||
<input type="file" id="file-input" style="display:none" multiple onchange="onFilesPicked(this.files)">
|
||
<input type="file" id="folder-input" style="display:none" webkitdirectory directory multiple onchange="onFilesPicked(this.files)">
|
||
<input type="file" id="folder-add-file-input" style="display:none" multiple onchange="onFolderAddFilesPicked(this.files)">
|
||
<button class="dt-cl-add-btn" onclick="document.getElementById('file-input').click()">📎 증거자료 업로드</button>
|
||
<button class="dt-cl-add-btn" onclick="document.getElementById('folder-input').click()">📁 폴더 업로드</button>
|
||
<select id="ev-sort-mode" onchange="onEvidenceSortChange()" style="margin-left:auto;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:11px;color:var(--text);font-family:inherit;">
|
||
<option value="category">분류별</option>
|
||
<option value="date">날짜순</option>
|
||
</select>
|
||
<span id="file-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||
</div>
|
||
<div class="dt-cl-list" id="files-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Submission documents pane -->
|
||
<div class="dt-tab-pane" id="pane-docs">
|
||
<div class="dt-cl-wrap">
|
||
<div class="dt-cl-add-bar">
|
||
<input type="file" id="doc-input" style="display:none" multiple onchange="onDocsPicked(this.files)">
|
||
<button class="dt-cl-add-btn" onclick="document.getElementById('doc-input').click()">📋 서류 업로드</button>
|
||
<span id="doc-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||
</div>
|
||
<div class="dt-cl-list" id="docs-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>
|
||
<span id="dt-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px" title="현재 대화 컨텍스트">일반</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>
|
||
|
||
<!-- Built-in text editor for evidence .txt/.md/.csv files -- avoids opening the
|
||
raw file in a new tab, which garbles legacy EUC-KR/CP949 Korean documents
|
||
under the browser's UTF-8 assumption. -->
|
||
<div id="dt-text-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9999;align-items:center;justify-content:center;padding:18px">
|
||
<div style="width:min(720px,96vw);height:min(640px,90vh);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md,0 8px 30px rgba(0,0,0,.3));display:flex;flex-direction:column;overflow:hidden">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
|
||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||
<span style="font-weight:800;font-size:13px;flex-shrink:0">📝</span>
|
||
<span id="dt-te-filename" style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||
<select id="dt-te-encoding" onchange="dtTextEditorRedecode()" style="margin-left:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit">
|
||
<option value="utf-8">UTF-8</option>
|
||
<option value="euc-kr">EUC-KR (CP949)</option>
|
||
</select>
|
||
</div>
|
||
<button onclick="closeTextEditor()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1;flex-shrink:0">✕</button>
|
||
</div>
|
||
<textarea id="dt-te-textarea" style="flex:1;resize:none;border:0;outline:none;padding:14px 16px;font-family:'IBM Plex Mono',monospace;font-size:13px;line-height:1.6;background:var(--panel);color:var(--text)"></textarea>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0">
|
||
<span id="dt-te-status" style="font-size:11px;color:var(--muted)"></span>
|
||
<div style="display:flex;gap:8px">
|
||
<button onclick="closeTextEditor()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">닫기</button>
|
||
<button onclick="saveTextEditor()" style="border:0;background:var(--brand);color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">저장 (UTF-8)</button>
|
||
</div>
|
||
</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('homeclaw_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','files','docs'].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();renderDateSuggestions();}
|
||
if(tab==='files')loadFiles();
|
||
if(tab==='docs')loadSubmissionDocs();
|
||
}
|
||
|
||
// ── 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.dateSuggestions)c.dateSuggestions=[];
|
||
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();
|
||
}
|
||
|
||
// timeline/dateSuggestions can be written server-side out-of-band, outside
|
||
// this full-array save entirely — the background date-recognition pipeline
|
||
// (routes-detective-dates.ts) writes those two fields directly to
|
||
// detective-cases.json, and another browser tab on the same case could too.
|
||
// A blind full-array PUT here would silently erase whatever a background
|
||
// call or another tab already persisted between this tab's last load and
|
||
// now. Before saving, merge in any timeline/dateSuggestions entries the
|
||
// server has that this tab doesn't know about yet (union by id — local
|
||
// entries always win on conflict, since a just-made local edit is the
|
||
// freshest truth). This can't perfectly reconcile deletions made in another
|
||
// tab, but silently resurrecting an already-deleted entry is a far smaller
|
||
// problem than silently losing one that was just added.
|
||
async function saveCases(){
|
||
updateCurrentCaseFromDom();
|
||
try{
|
||
try{
|
||
const r=await fetch('/api/detective/cases',{headers:authH()});
|
||
if(r.ok){
|
||
const d=await r.json();
|
||
if(Array.isArray(d.cases)){
|
||
for(const freshCase of d.cases){
|
||
const localCase=cases.find(x=>x.id===freshCase.id);
|
||
if(!localCase)continue;
|
||
for(const field of['timeline','dateSuggestions']){
|
||
const localIds=new Set((localCase[field]||[]).map(x=>x.id));
|
||
const merged=[...(localCase[field]||[])];
|
||
for(const item of(freshCase[field]||[])){
|
||
if(!localIds.has(item.id))merged.push(item);
|
||
}
|
||
localCase[field]=merged;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}catch{}
|
||
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(activeTab==='files')loadFiles();
|
||
if(activeTab==='docs')loadSubmissionDocs();
|
||
ensureChatContextForCase();
|
||
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);
|
||
fetch('/api/chat/sessions/'+encodeURIComponent('dt_case_'+id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||
if(currentCaseId===id){
|
||
currentCaseId=null;
|
||
document.getElementById('center-empty').style.display='';
|
||
document.getElementById('center-case').style.display='none';
|
||
ensureChatContextForCase();
|
||
}
|
||
await saveCases();
|
||
renderCaseList();
|
||
}
|
||
|
||
function onCaseNameChange(){
|
||
const c=cases.find(x=>x.id===currentCaseId);
|
||
if(c)c.title=document.getElementById('case-name-input').value;
|
||
renderCaseList();
|
||
const label=document.getElementById('dt-chat-ctx-label');
|
||
if(label&&c)label.textContent=c.title||'제목 없음';
|
||
}
|
||
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 */}
|
||
function handleNotesTab(e){
|
||
if(e.key!=='Tab')return;
|
||
e.preventDefault();
|
||
const ta=e.target;
|
||
const start=ta.selectionStart,end=ta.selectionEnd;
|
||
ta.value=ta.value.slice(0,start)+'\t'+ta.value.slice(end);
|
||
ta.selectionStart=ta.selectionEnd=start+1;
|
||
onNotesChange();
|
||
}
|
||
|
||
// ── 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" style="display:flex;align-items:center;gap:8px">
|
||
${ev.url?(DT_PHOTO_EXTS.test(ev.url)
|
||
?`<a href="${escAttr(ev.url)}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(ev.url)}" loading="lazy" style="width:36px;height:36px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`
|
||
:`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(ev.relPath||'')}','${escJsAttr(ev.url)}','${escJsAttr((ev.relPath||'').split('/').pop())}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:5px;border:1px solid var(--line);font-size:16px">📄</span></a>`
|
||
):''}
|
||
<span style="flex:1">${escHtml(ev.text)}</span>
|
||
<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();
|
||
}
|
||
|
||
async function deleteEvent(id){
|
||
const c=currentCase();if(!c)return;
|
||
const ev=(c.timeline||[]).find(x=>x.id===id);
|
||
// Entries linked to an evidence file (photo/document added via date
|
||
// recognition) get an extra choice: removing just the timeline entry
|
||
// leaves the file in the evidence tab untouched; confirming also deletes
|
||
// the working file (the untouched original still sits in 원본/ either way).
|
||
if(ev&&ev.relPath&&confirm(`타임라인 항목을 삭제합니다.\n연결된 파일도 함께 삭제할까요?\n"${ev.relPath}"\n\n확인 = 파일도 삭제 (원본은 원본/ 폴더에 남음)\n취소 = 타임라인 항목만 삭제, 파일은 유지`)){
|
||
try{
|
||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:ev.relPath})});
|
||
}catch{}
|
||
if(activeTab==='files')loadFiles();
|
||
}
|
||
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
|
||
renderTimeline();saveCases();
|
||
}
|
||
|
||
// ── Date recognition (photos: EXIF → vision LLM; text docs: content read) ────
|
||
// Runs automatically right after upload and can be re-run per file from the
|
||
// evidence tab. Results land as pending suggestions here — approving one
|
||
// copies it into the timeline, it never gets added silently.
|
||
const DT_PHOTO_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff)$/i;
|
||
const DT_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
|
||
const DT_TIMELINE_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff|txt|md|csv)$/i;
|
||
|
||
async function requestDateSuggestion(relPath){
|
||
if(!currentCaseId)return null;
|
||
try{
|
||
const r=await fetch('/api/detective/files/date-suggest',{
|
||
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||
body:JSON.stringify({caseId:currentCaseId,relPath}),
|
||
});
|
||
const d=await r.json();
|
||
if(!d.success||!d.suggestion)return null;
|
||
const c=currentCase();
|
||
if(c){
|
||
if(!c.dateSuggestions)c.dateSuggestions=[];
|
||
c.dateSuggestions=c.dateSuggestions.filter(s=>s.relPath!==relPath);
|
||
c.dateSuggestions.push(d.suggestion);
|
||
}
|
||
return d.suggestion;
|
||
}catch{return null;}
|
||
}
|
||
|
||
async function dtManualDateSuggest(relPath,btn){
|
||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||
const s=await requestDateSuggestion(relPath);
|
||
if(btn){btn.disabled=false;btn.textContent='📅';}
|
||
renderDateSuggestions();
|
||
showSaveIndicator(s?(s.date?`날짜 인식: ${s.date}`:'날짜를 찾지 못했습니다'):'인식 실패');
|
||
}
|
||
|
||
// Runs `worker` over `items` with at most `concurrency` in flight at once —
|
||
// each date-recognition call is a slow (sometimes 60-90s) LLM round-trip, so
|
||
// fully sequential batches turned a 20-photo folder into a 20-30 minute wait.
|
||
// Different files never touch each other's state, so a small concurrency cap
|
||
// is safe and gives a large speedup without hammering the LLM endpoint.
|
||
async function runWithConcurrency(items,worker,concurrency){
|
||
let next=0;
|
||
async function run(){
|
||
while(next<items.length){
|
||
const i=next++;
|
||
await worker(items[i],i);
|
||
}
|
||
}
|
||
await Promise.all(Array.from({length:Math.min(concurrency,items.length)},run));
|
||
}
|
||
|
||
// Recognizes every photo/text document in a folder at once, then hands off
|
||
// to the Timeline tab's suggestion panel where all of them can be reviewed
|
||
// and committed together with approveAllDateSuggestions() instead of one at
|
||
// a time.
|
||
async function dtFolderRecognize(folder,btn){
|
||
const items=dtLastEvidenceFiles.filter(f=>f.folder===folder&&(f.category==='photo'||DT_TEXT_EDIT_EXTS.test(f.name)));
|
||
if(!items.length){alert('이 폴더에 인식할 사진/문서가 없습니다.');return;}
|
||
if(!confirm(`"${folder}" 폴더의 사진/문서 ${items.length}개에서 날짜를 인식합니다.\n(개수에 따라 시간이 걸릴 수 있습니다)`))return;
|
||
const label=btn?btn.textContent:'';
|
||
if(btn)btn.disabled=true;
|
||
let progress=0;
|
||
await runWithConcurrency(items,async(item)=>{
|
||
await requestDateSuggestion(item.relPath);
|
||
progress++;
|
||
if(btn)btn.textContent=`인식 중... (${progress}/${items.length})`;
|
||
},3);
|
||
if(btn){btn.disabled=false;btn.textContent=label;}
|
||
switchTab('timeline');
|
||
showSaveIndicator(`${items.length}장 인식 완료 — 아래에서 확인 후 일괄 추가하세요`);
|
||
}
|
||
|
||
const DT_SOURCE_LABELS={exif:'EXIF',vision:'AI 인식','vision+exif_year':'AI 인식(연도 추정)',text:'AI 인식(문서)'};
|
||
|
||
function renderDateSuggestions(){
|
||
const box=document.getElementById('tl-suggestions');
|
||
if(!box)return;
|
||
const c=currentCase();
|
||
const list=c?(c.dateSuggestions||[]):[];
|
||
if(!list.length){box.innerHTML='';return;}
|
||
box.innerHTML=`<div style="margin:10px 0;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel)">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||
<span style="font-size:11px;color:var(--muted);flex:1">📷📄 사진/문서에서 인식된 날짜 제안 (${list.length}) — 확인 후 추가하세요</span>
|
||
<button class="dt-tl-add-btn" style="padding:2px 8px" id="sug-approve-all-btn" onclick="approveAllDateSuggestions()">날짜 있는 항목 전체 추가</button>
|
||
</div>
|
||
${list.map(s=>`
|
||
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;font-size:12px;border-top:1px solid var(--line)">
|
||
${s.kind==='text'
|
||
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(s.relPath)}','${escJsAttr(s.url||'')}','${escJsAttr(s.fileName)}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:5px;border:1px solid var(--line);font-size:18px">📄</span></a>`
|
||
:`<a href="${escAttr(s.url||'')}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(s.url||'')}" loading="lazy" style="width:44px;height:44px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`}
|
||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||
<span style="font-size:10px;color:var(--muted);word-break:break-all">${escHtml(s.fileName)}</span>
|
||
<input type="text" id="sug-name-${s.id}" value="${escAttr(s.nameOverride||'')}" placeholder="새 파일 이름 (비우면 원본 이름 유지)" style="background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:3px 6px;font-size:12px;color:var(--text);font-family:inherit;width:100%">
|
||
</div>
|
||
<span style="color:var(--muted);font-size:10px;flex-shrink:0">${DT_SOURCE_LABELS[s.source]||s.source}</span>
|
||
<input type="date" id="sug-date-${s.id}" value="${s.date?escAttr(s.date):''}" class="dt-tl-date" style="width:130px;flex-shrink:0">
|
||
<button class="dt-tl-add-btn" style="padding:2px 8px" onclick="approveDateSuggestion('${s.id}')">타임라인에 추가</button>
|
||
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="rejectDateSuggestion('${s.id}')" title="제안만 지우기 (파일은 유지)">✕</button>
|
||
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="deleteFile('${escJsAttr(s.relPath)}')" title="파일 자체를 삭제">🗑</button>
|
||
</div>
|
||
${s.reason?`<div style="font-size:10px;color:var(--muted);margin:-2px 0 4px 52px">${escHtml(s.reason)}</div>`:''}
|
||
`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// Committing a suggestion actually touches the evidence file: renames it to
|
||
// "<date>_<content-or-original-name>.<ext>" and copies the untouched original
|
||
// into a 원본/ subfolder next to it, so a wrong AI guess never loses the
|
||
// source file. All done server-side in one call — see commitPhotoToTimeline.
|
||
async function commitDateSuggestion(caseId,relPath,date,label){
|
||
const r=await fetch('/api/detective/files/add-to-timeline',{
|
||
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||
body:JSON.stringify({caseId,relPath,date,label}),
|
||
});
|
||
const d=await r.json();
|
||
if(!d.success)throw new Error(d.error||'add-to-timeline failed');
|
||
return d;
|
||
}
|
||
|
||
// Shared by approveDateSuggestion/approveAllDateSuggestions: commits one
|
||
// suggestion server-side, then mirrors the exact id/text/relPath/url the
|
||
// server persisted into the local timeline (never re-derive the icon/id
|
||
// client-side — the server already knows whether this was a photo or a
|
||
// text document, and re-guessing here is how the timeline icon used to
|
||
// drift out of sync with what's actually on disk).
|
||
async function commitOneSuggestion(c,s,date,name){
|
||
const result=await commitDateSuggestion(currentCaseId,s.relPath,date,name);
|
||
if(!c.timeline)c.timeline=[];
|
||
c.timeline.push({id:result.id,date,text:result.text,relPath:result.relPath,url:result.url});
|
||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==s.id);
|
||
return result;
|
||
}
|
||
|
||
async function approveDateSuggestion(id){
|
||
const c=currentCase();if(!c)return;
|
||
const s=(c.dateSuggestions||[]).find(x=>x.id===id);
|
||
if(!s)return;
|
||
const date=document.getElementById('sug-date-'+id)?.value;
|
||
if(!date){alert('날짜를 입력하세요.');return;}
|
||
const name=document.getElementById('sug-name-'+id)?.value||'';
|
||
try{
|
||
await commitOneSuggestion(c,s,date,name);
|
||
}catch(e){alert('타임라인 추가 실패: '+e.message);return;}
|
||
renderTimeline();renderDateSuggestions();loadFiles();
|
||
showSaveIndicator('타임라인에 추가됨');
|
||
}
|
||
|
||
function rejectDateSuggestion(id){
|
||
const c=currentCase();if(!c)return;
|
||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==id);
|
||
renderDateSuggestions();saveCases();
|
||
}
|
||
|
||
// Commits every pending suggestion that currently has a date filled in (the
|
||
// per-row input, editable before this runs). Runs with a small concurrency
|
||
// cap rather than fully sequential or fully parallel — each commit is a slow
|
||
// server round-trip (rename + backup + JSON write) but nothing stops two
|
||
// different files' commits from happening at once, and fully serializing a
|
||
// folder of 10+ suggestions made this take minutes for no reason.
|
||
async function approveAllDateSuggestions(){
|
||
const c=currentCase();if(!c)return;
|
||
const list=[...(c.dateSuggestions||[])];
|
||
if(!list.length)return;
|
||
const btn=document.getElementById('sug-approve-all-btn');
|
||
if(btn)btn.disabled=true;
|
||
let done=0,failed=0,skipped=0;
|
||
const CONCURRENCY=3;
|
||
let next=0;
|
||
async function worker(){
|
||
while(next<list.length){
|
||
const s=list[next++];
|
||
const date=document.getElementById('sug-date-'+s.id)?.value;
|
||
if(!date){skipped++;continue;}
|
||
const name=document.getElementById('sug-name-'+s.id)?.value||'';
|
||
try{
|
||
await commitOneSuggestion(c,s,date,name);
|
||
done++;
|
||
}catch{failed++;}
|
||
if(btn)btn.textContent=`추가 중... (${done+failed+skipped}/${list.length})`;
|
||
}
|
||
}
|
||
await Promise.all(Array.from({length:Math.min(CONCURRENCY,list.length)},worker));
|
||
renderTimeline();renderDateSuggestions();loadFiles();
|
||
showSaveIndicator(`${done}건 추가${skipped?`, ${skipped}건 날짜 없어 건너뜀`:''}${failed?`, ${failed}건 실패`:''}`);
|
||
}
|
||
|
||
// Direct "recognize" from the evidence tab itself — recognizes the date if
|
||
// it hasn't been already, then hands off to the Timeline tab's suggestion
|
||
// panel for review. It never commits by itself: renaming the file and
|
||
// backing up the original only happens after the user explicitly checks the
|
||
// date/name there and clicks "타임라인에 추가" — a native prompt() popup isn't
|
||
// a real review step, so this button doesn't skip straight to committing.
|
||
async function dtQuickAddToTimeline(relPath,btn){
|
||
const c=currentCase();if(!c)return;
|
||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||
let s=(c.dateSuggestions||[]).find(x=>x.relPath===relPath);
|
||
if(!s)s=await requestDateSuggestion(relPath);
|
||
if(btn){btn.disabled=false;btn.textContent='🕐+';}
|
||
if(!s){alert('날짜 인식에 실패했습니다.');return;}
|
||
switchTab('timeline');
|
||
showSaveIndicator(s.date?`날짜 인식: ${s.date} — 아래에서 확인 후 추가하세요`:'날짜를 찾지 못했습니다 — 아래에서 직접 입력 후 추가하세요');
|
||
}
|
||
|
||
async function dtFetchFromNextcloud(relPath,btn){
|
||
if(!currentCaseId)return;
|
||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||
try{
|
||
const url='/api/detective/nextcloud-fetch?caseId='+encodeURIComponent(currentCaseId)+'&relPath='+encodeURIComponent(relPath);
|
||
const r=await fetch(url,{headers:authH()});
|
||
const d=await r.json();
|
||
if(!r.ok||!d.success)throw new Error(d.error||'가져오기 실패');
|
||
const names=(d.files||[]).map(f=>f.name).join(', ');
|
||
showSaveIndicator(`NextCloud에서 최신본을 가져왔습니다: ${names}`);
|
||
loadFiles();
|
||
}catch(e){
|
||
alert('NextCloud에서 가져오기 실패: '+e.message);
|
||
}finally{
|
||
if(btn){btn.disabled=false;btn.textContent='⬇️';}
|
||
}
|
||
}
|
||
|
||
// ── Files ─────────────────────────────────────────────────────────────────────
|
||
function fmtFileSize(n){
|
||
if(n<1024)return n+'B';
|
||
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||
return (n/1024/1024).toFixed(1)+'MB';
|
||
}
|
||
|
||
async function loadFiles(){
|
||
const list=document.getElementById('files-list');
|
||
if(!currentCaseId){list.innerHTML='';return;}
|
||
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||
try{
|
||
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||
const d=await r.json();
|
||
const all=Array.isArray(d.files)?d.files:[];
|
||
// Root-level documents live in the 제출서류 tab instead; documents
|
||
// inside a folder upload stay put so that folder's structure is intact.
|
||
renderFiles(all.filter(f=>f.folder||f.category!=='document'));
|
||
}catch{list.innerHTML='<div class="dt-cl-empty">파일 목록을 불러오지 못했습니다.</div>';}
|
||
}
|
||
|
||
async function loadSubmissionDocs(){
|
||
const list=document.getElementById('docs-list');
|
||
if(!currentCaseId){list.innerHTML='';return;}
|
||
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||
try{
|
||
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||
const d=await r.json();
|
||
const all=Array.isArray(d.files)?d.files:[];
|
||
const docs=all.filter(f=>!f.folder&&f.category==='document').sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||
if(!docs.length){list.innerHTML='<div class="dt-cl-empty">등록된 제출서류가 없습니다.</div>';return;}
|
||
list.innerHTML=docs.map(f=>renderFileRow(f)).join('');
|
||
}catch{list.innerHTML='<div class="dt-cl-empty">서류 목록을 불러오지 못했습니다.</div>';}
|
||
}
|
||
|
||
async function onDocsPicked(fileList){
|
||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||
const status=document.getElementById('doc-upload-status');
|
||
const files=[...fileList];
|
||
for(let i=0;i<files.length;i++){
|
||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||
try{await uploadOneFile(files[i]);}catch{}
|
||
}
|
||
status.textContent='';
|
||
document.getElementById('doc-input').value='';
|
||
loadSubmissionDocs();
|
||
}
|
||
|
||
const EVIDENCE_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||
const EVIDENCE_CATEGORY_ORDER=['photo','video','document','other'];
|
||
|
||
const EVIDENCE_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
|
||
let evidenceSortMode='category'; // 'category' | 'date'
|
||
|
||
function onEvidenceSortChange(){
|
||
evidenceSortMode=document.getElementById('ev-sort-mode').value;
|
||
loadFiles();
|
||
}
|
||
|
||
// name: display label. Root files show their bare name; files inside a
|
||
// folder-upload show the path relative to that folder, so the original
|
||
// structure stays visible instead of being flattened away.
|
||
function renderFileRow(f,name){
|
||
const isPdf=/\.pdf$/i.test(f.name);
|
||
const isCloudEditable=/\.(hwp|odt)$/i.test(f.name);
|
||
const isText=DT_TEXT_EDIT_EXTS.test(f.name);
|
||
const icon=EVIDENCE_CATEGORY_ICONS[f.category]||'📦';
|
||
const nameHtml=isText
|
||
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(f.relPath)}','${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(name||f.name)}</a>`
|
||
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(name||f.name)}</a>`;
|
||
return `<div class="dt-cl-item">
|
||
<span style="flex-shrink:0">${icon}</span>
|
||
${nameHtml}
|
||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
|
||
${isPdf?`<a href="/pdf-editor-open?caseId=${encodeURIComponent(currentCaseId)}" target="_blank" class="dt-cl-del" style="opacity:.7" title="PDF 편집기를 새 창에서 엽니다. 이 파일은 자동으로 열리지 않으니(외부 편집기 한계), 편집기의 업로드 버튼에서 이 파일을 직접 선택하세요. 수정 후 다운로드하면 사건 폴더에 자동 저장됩니다.">✏️</a>`:''}
|
||
${isCloudEditable?`<a href="/api/detective/nextcloud-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="dt-cl-del" style="opacity:.7" title="NextCloud(Collabora)에서 바로 편집합니다. 새 창이 열리면 그 문서에 즉시 편집 가능하고, 입력하는 대로 자동 저장됩니다(다운로드 불필요).">🌐</a>
|
||
<button class="dt-cl-del" onclick="dtFetchFromNextcloud('${escJsAttr(f.relPath)}',this)" title="NextCloud에서 편집 중인(또는 편집 완료한) 최신본을 사건 폴더로 가져옵니다(odt+PDF).">⬇️</button>`:''}
|
||
${(f.category==='photo'||isText)?`<button class="dt-cl-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)">🕐+</button>
|
||
<button class="dt-cl-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="날짜 인식">📅</button>`:''}
|
||
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dt-cl-del" title="기기에 다운로드">💾</a>
|
||
<button class="dt-cl-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderCategoryGroups(files){
|
||
const groups={};
|
||
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||
return EVIDENCE_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
|
||
const items=groups[cat];
|
||
const title=`<div class="dt-ev-group-title">${EVIDENCE_CATEGORY_LABELS[cat]} (${items.length})</div>`;
|
||
if(cat==='photo'){
|
||
return title+`<div class="dt-ev-photo-grid">${items.map(f=>`
|
||
<div class="dt-ev-photo">
|
||
<a href="${escAttr(f.url)}" target="_blank" title="${escAttr(f.name)}"><img src="${escAttr(f.url)}" loading="lazy" alt="${escAttr(f.name)}"></a>
|
||
<button class="dt-ev-photo-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)" style="right:74px">🕐+</button>
|
||
<button class="dt-ev-photo-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="사진에서 날짜 인식" style="right:50px">📅</button>
|
||
<a href="${escAttr(f.url)}" download="${escAttr(f.name)}" class="dt-ev-photo-del" title="기기에 다운로드" style="right:26px">💾</a>
|
||
<button class="dt-ev-photo-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||
</div>`).join('')}</div>`;
|
||
}
|
||
return title+items.map(f=>renderFileRow(f)).join('');
|
||
}).join('');
|
||
}
|
||
|
||
// Folder uploads keep their original structure (no photo/video/document
|
||
// splitting) but are grouped by the date each file was added. Order within
|
||
// the folder (newest-first vs oldest-first) is per-folder, see dtGetFolderOrder.
|
||
function renderFolderContents(items,order){
|
||
const dateGroups={};
|
||
items.forEach(f=>{
|
||
const d=new Date(f.mtime);
|
||
const key=isNaN(d)?'날짜 없음':d.toISOString().slice(0,10);
|
||
(dateGroups[key]=dateGroups[key]||[]).push(f);
|
||
});
|
||
const dir=order==='asc'?1:-1;
|
||
return Object.keys(dateGroups).sort((a,b)=>dir*b.localeCompare(a)).map(dateKey=>{
|
||
const dItems=dateGroups[dateKey].sort((a,b)=>dir*(new Date(b.mtime)-new Date(a.mtime)));
|
||
return `<div class="dt-ev-date-title">📅 ${dateKey} (${dItems.length})</div>`
|
||
+dItems.map(f=>renderFileRow(f,f.relPath.slice(f.folder.length+1))).join('');
|
||
}).join('');
|
||
}
|
||
|
||
// Per-folder item order (newest/oldest first) and folder-list order
|
||
// (name/date), both persisted in localStorage -- folder order per case,
|
||
// list order globally since it's a display preference, not case data.
|
||
function dtFolderOrderKey(folder){return 'dt_folder_order_'+(currentCaseId||'')+'_'+folder;}
|
||
function dtGetFolderOrder(folder){return localStorage.getItem(dtFolderOrderKey(folder))||'desc';}
|
||
function dtSetFolderOrder(folder,order){try{localStorage.setItem(dtFolderOrderKey(folder),order);}catch{}}
|
||
function dtToggleFolderOrder(folder){
|
||
dtSetFolderOrder(folder,dtGetFolderOrder(folder)==='desc'?'asc':'desc');
|
||
renderFiles(dtLastEvidenceFiles);
|
||
}
|
||
|
||
let folderSortMode=localStorage.getItem('dt_folder_sort_mode')||'name'; // 'name' | 'date'
|
||
function onFolderSortChange(){
|
||
folderSortMode=document.getElementById('folder-sort-mode').value;
|
||
try{localStorage.setItem('dt_folder_sort_mode',folderSortMode);}catch{}
|
||
renderFiles(dtLastEvidenceFiles);
|
||
}
|
||
function sortedFolderNames(folderMap){
|
||
const names=Object.keys(folderMap);
|
||
if(folderSortMode==='date'){
|
||
const latest=folder=>Math.max(...folderMap[folder].map(f=>new Date(f.mtime).getTime()||0));
|
||
return names.sort((a,b)=>latest(b)-latest(a));
|
||
}
|
||
return names.sort();
|
||
}
|
||
|
||
// Remembers which folders are collapsed (per case, in localStorage) so
|
||
// re-rendering the list -- after an upload, a tab switch, or even a full
|
||
// page reload -- doesn't snap every folder back open.
|
||
function dtCollapsedKey(){return 'dt_collapsed_'+(currentCaseId||'');}
|
||
function dtLoadCollapsed(){
|
||
try{return new Set(JSON.parse(localStorage.getItem(dtCollapsedKey())||'[]'));}catch{return new Set();}
|
||
}
|
||
function dtSaveCollapsed(set){
|
||
try{localStorage.setItem(dtCollapsedKey(),JSON.stringify([...set]));}catch{}
|
||
}
|
||
|
||
function dtToggleFolder(headerEl){
|
||
const folder=headerEl.dataset.folder;
|
||
const body=headerEl.nextElementSibling;
|
||
const collapsed=body.style.display==='none';
|
||
body.style.display=collapsed?'':'none';
|
||
const chev=headerEl.querySelector('.dt-ev-chevron');
|
||
if(chev)chev.textContent=collapsed?'▾':'▸';
|
||
const set=dtLoadCollapsed();
|
||
if(collapsed)set.delete(folder);else set.add(folder);
|
||
dtSaveCollapsed(set);
|
||
}
|
||
|
||
let dtLastEvidenceFiles=[];
|
||
|
||
function renderFiles(files){
|
||
dtLastEvidenceFiles=files;
|
||
const list=document.getElementById('files-list');
|
||
if(!files.length){list.innerHTML='<div class="dt-cl-empty">등록된 증거자료가 없습니다.</div>';return;}
|
||
|
||
if(evidenceSortMode==='date'){
|
||
const sorted=[...files].sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||
list.innerHTML=sorted.map(f=>renderFileRow(f,f.relPath)).join('');
|
||
return;
|
||
}
|
||
|
||
const rootFiles=files.filter(f=>!f.folder);
|
||
const folderMap={};
|
||
files.filter(f=>f.folder).forEach(f=>{(folderMap[f.folder]=folderMap[f.folder]||[]).push(f);});
|
||
|
||
let html=rootFiles.length?renderCategoryGroups(rootFiles):'';
|
||
const collapsedSet=dtLoadCollapsed();
|
||
const folderSortBar=Object.keys(folderMap).length?`<div style="display:flex;justify-content:flex-end;margin-top:8px">
|
||
<select id="folder-sort-mode" onchange="onFolderSortChange()" style="background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:10px;color:var(--text);font-family:inherit;">
|
||
<option value="name"${folderSortMode==='name'?' selected':''}>폴더: 이름순</option>
|
||
<option value="date"${folderSortMode==='date'?' selected':''}>폴더: 최신순</option>
|
||
</select>
|
||
</div>`:'';
|
||
html+=folderSortBar;
|
||
sortedFolderNames(folderMap).forEach(folder=>{
|
||
const items=folderMap[folder];
|
||
const collapsed=collapsedSet.has(folder);
|
||
const order=dtGetFolderOrder(folder);
|
||
html+=`<div class="dt-ev-folder-header" data-folder="${escAttr(folder)}" onclick="dtToggleFolder(this)">
|
||
<span><span class="dt-ev-chevron">${collapsed?'▸':'▾'}</span> 📁 ${escHtml(folder)} (${items.length})</span>
|
||
<span>
|
||
<button class="dt-cl-del" onclick="event.stopPropagation();dtAddFilesToFolder('${escJsAttr(folder)}')" title="이 폴더에 파일 추가" style="opacity:.6;margin-right:8px">+ 파일 추가</button>
|
||
<button class="dt-cl-del" onclick="event.stopPropagation();dtFolderRecognize('${escJsAttr(folder)}',this)" title="폴더 내 사진 전체 날짜 인식 (타임라인 탭에서 확인 후 일괄 추가)" style="opacity:.6;margin-right:8px">🕐+ 폴더 인식</button>
|
||
<button class="dt-cl-del" onclick="event.stopPropagation();dtToggleFolderOrder('${escJsAttr(folder)}')" title="폴더 내 파일 정렬 순서 전환" style="opacity:.6;margin-right:8px">${order==='asc'?'🔼 오래된순':'🔽 최신순'}</button>
|
||
<a href="/api/detective/files/download-folder?caseId=${encodeURIComponent(currentCaseId)}&folder=${encodeURIComponent(folder)}" onclick="event.stopPropagation()" class="dt-cl-del" title="폴더 전체를 zip으로 다운로드" style="opacity:.6;margin-right:8px">💾 폴더 다운로드</a>
|
||
<button class="dt-cl-del" onclick="event.stopPropagation();deleteFolder('${escJsAttr(folder)}')" title="폴더 전체 삭제" style="opacity:.6">🗑 폴더 삭제</button>
|
||
</span>
|
||
</div><div class="dt-ev-folder-body"${collapsed?' style="display:none"':''}>`+renderFolderContents(items,order)+`</div>`;
|
||
});
|
||
list.innerHTML=html;
|
||
}
|
||
|
||
async function onFilesPicked(fileList){
|
||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||
const status=document.getElementById('file-upload-status');
|
||
const files=[...fileList];
|
||
const recognizeRelPaths=[];
|
||
for(let i=0;i<files.length;i++){
|
||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||
try{
|
||
const d=await uploadOneFile(files[i]);
|
||
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||
}catch{}
|
||
}
|
||
document.getElementById('file-input').value='';
|
||
document.getElementById('folder-input').value='';
|
||
loadFiles();
|
||
let progress=0;
|
||
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||
await requestDateSuggestion(relPath);
|
||
progress++;
|
||
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||
},3);
|
||
status.textContent='';
|
||
renderDateSuggestions();
|
||
}
|
||
|
||
// Adds file(s) directly into an existing evidence folder, instead of the
|
||
// only previous options (root upload, or a whole new folder via directory
|
||
// picker). Reuses uploadOneFile with an explicit targetName so the server
|
||
// places it under <folder>/<name> rather than at the root.
|
||
let dtFolderAddTarget=null;
|
||
function dtAddFilesToFolder(folder){
|
||
dtFolderAddTarget=folder;
|
||
document.getElementById('folder-add-file-input').click();
|
||
}
|
||
|
||
async function onFolderAddFilesPicked(fileList){
|
||
if(!currentCaseId||!dtFolderAddTarget)return;
|
||
const folder=dtFolderAddTarget;
|
||
const status=document.getElementById('file-upload-status');
|
||
const files=[...fileList];
|
||
const recognizeRelPaths=[];
|
||
for(let i=0;i<files.length;i++){
|
||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||
try{
|
||
const targetName=folder+'/'+files[i].name;
|
||
const d=await uploadOneFile(files[i],targetName);
|
||
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||
}catch{}
|
||
}
|
||
document.getElementById('folder-add-file-input').value='';
|
||
dtFolderAddTarget=null;
|
||
loadFiles();
|
||
let progress=0;
|
||
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||
await requestDateSuggestion(relPath);
|
||
progress++;
|
||
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||
},3);
|
||
status.textContent='';
|
||
renderDateSuggestions();
|
||
}
|
||
|
||
function uploadOneFile(file,targetName){
|
||
return new Promise((resolve,reject)=>{
|
||
const fd=new FormData();
|
||
// webkitRelativePath (folder uploads) keeps the subfolder as part of the
|
||
// filename so files from different subfolders don't silently collide.
|
||
// targetName overrides both when adding a file into a specific existing
|
||
// folder (see dtAddFilesToFolder).
|
||
const name=targetName||file.webkitRelativePath||file.name;
|
||
fd.append('file',file,name);
|
||
fetch('/api/detective/upload?caseId='+encodeURIComponent(currentCaseId),{
|
||
method:'POST',headers:authH(),body:fd,
|
||
}).then(r=>r.json()).then(d=>d.success?resolve(d):reject(d)).catch(reject);
|
||
});
|
||
}
|
||
|
||
// ── Built-in text editor (.txt/.md/.csv) ─────────────────────────────────────
|
||
// Old Korean documents are often EUC-KR/CP949, not UTF-8. Opening them via a
|
||
// plain <a target=_blank> lets the browser guess UTF-8 and garble the text,
|
||
// so this fetches the raw bytes and decodes them client-side instead, with a
|
||
// manual encoding override in case auto-detect picks the wrong one.
|
||
let dtTeState=null; // {caseId, relPath, name, bytes}
|
||
|
||
function decodeTextBytes(buf,encoding){
|
||
if(encoding){
|
||
try{return new TextDecoder(encoding).decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||
}
|
||
try{return new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{}
|
||
try{return new TextDecoder('euc-kr').decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||
}
|
||
|
||
async function openTextEditor(relPath,url,name){
|
||
const modal=document.getElementById('dt-text-editor-modal');
|
||
const ta=document.getElementById('dt-te-textarea');
|
||
const status=document.getElementById('dt-te-status');
|
||
document.getElementById('dt-te-filename').textContent=name;
|
||
ta.value='불러오는 중...';
|
||
status.textContent='';
|
||
modal.style.display='flex';
|
||
try{
|
||
const r=await fetch(url,{headers:authH(),cache:'no-store'});
|
||
const buf=await r.arrayBuffer();
|
||
const isUtf8=(()=>{try{new TextDecoder('utf-8',{fatal:true}).decode(buf);return true;}catch{return false;}})();
|
||
dtTeState={caseId:currentCaseId,relPath,name,bytes:buf};
|
||
document.getElementById('dt-te-encoding').value=isUtf8?'utf-8':'euc-kr';
|
||
ta.value=decodeTextBytes(buf,isUtf8?'utf-8':'euc-kr');
|
||
status.textContent=isUtf8?'':'⚠️ UTF-8이 아닌 것으로 보여 EUC-KR로 자동 표시했습니다. 깨져 보이면 인코딩을 바꿔보세요.';
|
||
}catch(e){
|
||
ta.value='';
|
||
status.textContent='파일을 불러오지 못했습니다: '+e.message;
|
||
}
|
||
}
|
||
|
||
function dtTextEditorRedecode(){
|
||
if(!dtTeState)return;
|
||
const enc=document.getElementById('dt-te-encoding').value;
|
||
document.getElementById('dt-te-textarea').value=decodeTextBytes(dtTeState.bytes,enc);
|
||
document.getElementById('dt-te-status').textContent='';
|
||
}
|
||
|
||
function closeTextEditor(){
|
||
document.getElementById('dt-text-editor-modal').style.display='none';
|
||
dtTeState=null;
|
||
}
|
||
|
||
async function saveTextEditor(){
|
||
if(!dtTeState)return;
|
||
const status=document.getElementById('dt-te-status');
|
||
status.textContent='저장 중...';
|
||
try{
|
||
const content=document.getElementById('dt-te-textarea').value;
|
||
const r=await fetch('/api/detective/files/content',{
|
||
method:'PUT',headers:authH({'Content-Type':'application/json'}),
|
||
body:JSON.stringify({caseId:dtTeState.caseId,relPath:dtTeState.relPath,content}),
|
||
});
|
||
const d=await r.json();
|
||
if(!r.ok||!d.success)throw new Error(d.error||'save failed');
|
||
status.textContent='저장됨 (UTF-8)';
|
||
setTimeout(closeTextEditor,600);
|
||
}catch(e){
|
||
status.textContent='저장 실패: '+e.message;
|
||
}
|
||
}
|
||
|
||
// Keeps the timeline/suggestion panel in sync when a file is removed from the
|
||
// evidence tab directly, so a delete there doesn't leave a dangling entry
|
||
// with a broken thumbnail pointing at a file that no longer exists.
|
||
function dtPruneTimelineForPath(matches){
|
||
const c=currentCase();if(!c)return;
|
||
const before=(c.timeline||[]).length+(c.dateSuggestions||[]).length;
|
||
c.timeline=(c.timeline||[]).filter(x=>!x.relPath||!matches(x.relPath));
|
||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>!matches(x.relPath));
|
||
if((c.timeline.length+c.dateSuggestions.length)!==before){renderTimeline();renderDateSuggestions();saveCases();}
|
||
}
|
||
|
||
async function deleteFile(relPath){
|
||
if(!currentCaseId)return;
|
||
if(!confirm('이 파일을 삭제할까요?'))return;
|
||
try{
|
||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath})});
|
||
}catch{}
|
||
dtPruneTimelineForPath(p=>p===relPath);
|
||
if(activeTab==='docs')loadSubmissionDocs();else loadFiles();
|
||
}
|
||
|
||
async function deleteFolder(folder){
|
||
if(!currentCaseId)return;
|
||
if(!confirm(`'${folder}' 폴더의 파일을 전부 삭제할까요? 되돌릴 수 없습니다.`))return;
|
||
try{
|
||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:folder})});
|
||
}catch{}
|
||
dtPruneTimelineForPath(p=>p===folder||p.startsWith(folder+'/'));
|
||
const set=dtLoadCollapsed();
|
||
set.delete(folder);
|
||
dtSaveCollapsed(set);
|
||
loadFiles();
|
||
}
|
||
|
||
// ── 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;
|
||
// Each case gets its own AI conversation (derived straight from the case id,
|
||
// no extra field to persist) so investigating one case never leaks context
|
||
// into another. With no case selected, chat falls back to a general session.
|
||
function getSessionId(){
|
||
if(currentCaseId)return 'dt_case_'+currentCaseId;
|
||
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();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
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{}
|
||
}
|
||
|
||
// Switches the chat panel to whichever case is currently selected (or the
|
||
// general session when none is) and reloads that context's own history --
|
||
// this is what keeps each case's AI conversation separate from the others.
|
||
let _chatCtxCaseId='__unset__';
|
||
async function ensureChatContextForCase(){
|
||
if(_chatCtxCaseId===currentCaseId)return;
|
||
_chatCtxCaseId=currentCaseId;
|
||
const c=cases.find(x=>x.id===currentCaseId);
|
||
const label=document.getElementById('dt-chat-ctx-label');
|
||
if(label)label.textContent=c?c.title||'제목 없음':'일반';
|
||
await loadHistory();
|
||
addMsg('system',c?`📁 '${escHtml(c.title||'제목 없음')}' 사건 컨텍스트입니다. 이 대화는 이 사건에서만 이어집니다.`:'🔍 일반 조사 컨텍스트입니다. (선택된 사건 없음)');
|
||
}
|
||
|
||
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:portal.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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||
// For values interpolated into a single-quoted JS string literal INSIDE an
|
||
// onclick="..." attribute (e.g. onclick="deleteFile('${escJsAttr(relPath)}')").
|
||
// escAttr alone is NOT enough here: the browser HTML-decodes the attribute
|
||
// value before treating it as JS, so an escAttr'd quote (') still becomes
|
||
// a real ' by the time it reaches the JS parser and can break out of the
|
||
// string literal (e.g. a filename like x'),alert(1),('y). This escapes the
|
||
// backslash/quote for the JS-string layer first, then HTML-escapes the
|
||
// result for the attribute layer, so both decoding steps land safely.
|
||
function escJsAttr(s){return escAttr(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,''));}
|
||
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
|
||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
||
.replace(/^> (.+)$/gm,'<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&¤tCaseId){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;
|
||
const sid=getSessionId();
|
||
document.getElementById('chat-msgs').innerHTML='';
|
||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||
if(!currentCaseId){
|
||
_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();
|
||
loadSkill();
|
||
await Promise.all([loadCases(),loadModels()]);
|
||
await ensureChatContextForCase();
|
||
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
|
||
initAppVoice(sendMessage);
|
||
document.getElementById('chat-input').focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|