Files
homeclaw/web-ui/html/detective-app.html
T
kimandClaude Opus 5 d60019f004 fix: 앱 페이지 7개에 수식 렌더러 배선 — 애초에 KaTeX가 없었다
법률앱에서 `$\rightarrow$`가 날것으로 보인다는 신고. 통화 `$` 문제(f06955f)를
먼저 의심했지만 원인은 더 단순했다 — **앱 페이지들은 KaTeX를 아예 로드하지
않고 있었다.**

  index.html        katex O
  lawyer / detective / doctor / investor / accountant / mind / weather   전부 X

메인 채팅에만 chat-render.js의 renderKatexIn이 있었고, 앱 7개는 각자 자기
renderMd를 갖고 있어 수식이 통과할 길이 없었다. 공용 js/app/katex-inline.js로
빼서 전부에 붙였다(통화 `$` 보호 로직 포함).

addMsg에서 DOM 삽입 직후 호출한다. innerHTML은 <script>를 실행하지 않으므로
삽입 시점마다 명시적으로 불러야 한다. TTS 버튼이 innerText를 읽어가므로
그보다 먼저 렌더링되게 배치했다 — 안 그러면 음성이 "달러 백슬래시 rightarrow
달러"를 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:53:35 +09:00

2674 lines
152 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔍 탐정 어시스턴트</title>
<link rel="icon" type="image/png" sizes="64x64" href="/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;min-height:120px;}
.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>
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
<script src="/vendor/katex/katex.min.js"></script>
<script src="/vendor/katex/auto-render.min.js"></script>
<script src="/vendor/katex/mhchem.min.js"></script>
<script src="../js/app/katex-inline.js"></script>
<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('온라인 사기 피해를 입었습니다. 증거 수집 방법과 신고 절차를 알려주세요.')">🚨 온라인 사기 대응</button>
<button class="dt-qbtn" onclick="sendQuick('디지털 발자국(Digital Footprint) 조사 방법을 알려주세요. SNS, 이메일, 도메인, IP 기반 공개 정보 추적 절차를 설명해 주세요.')">🌐 디지털 발자국 추적</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('법률 관련 질문이 있습니다. 상황을 말씀드릴 테니 관련 법령·판례를 조사해서 답변해 주세요.')">⚖️ 법률 질문하기</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} — 아래에서 확인 후 추가하세요`:'날짜를 찾지 못했습니다 — 아래에서 직접 입력 후 추가하세요');
}
// ── 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/collabora-open?caseId=${encodeURIComponent(currentCaseId)}&relPath=${encodeURIComponent(f.relPath)}" target="_blank" class="dt-cl-del" style="opacity:.7" title="Collabora에서 바로 편집합니다. 새 창이 열리면 그 문서에 즉시 편집 가능하고, 입력하는 대로 사건 폴더에 자동 저장됩니다(다운로드/가져오기 불필요).">🌐</a>`:''}
${(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,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
// 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 (&#39;) 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;
// 수식은 DOM 삽입 후에 렌더링해야 한다(innerHTML은 <script>를 실행하지 않는다).
if(role!=='user'&&window.renderKatexIn)window.renderKatexIn(b);
div.appendChild(b);
if(role==='assistant'){const tb=document.createElement('button');tb.className='app-tts-btn';tb.title='음성으로 듣기';tb.textContent='🔊';tb.onclick=function(){playTTSText(tb,b.innerText);};div.appendChild(tb);}
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
return b;
}
function renderMd(text){
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```[\w]*\n?([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/\*(.+?)\*/g,'<em>$1</em>')
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
.replace(/^> (.+)$/gm,'<blockquote>$1</blockquote>')
.replace(/^[-*]\s+(.+)$/gm,'<li>$1</li>')
.replace(/(<li>[\s\S]+?<\/li>)/g,m=>`<ul style="padding-left:16px;margin:3px 0">${m}</ul>`)
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
.replace(/<p><\/p>/g,'').replace(/<p>(<[hup])/g,'$1').replace(/(<\/[hup][^>]*>)<\/p>/g,'$1');
}
async function sendMessage(){
const input=document.getElementById('chat-input');
const msg=input.value.trim();
if(!msg||document.getElementById('send-btn').disabled)return;
input.value='';autoResize(input);
if(window.innerWidth<=1024)mobToggle('chat');
addMsg('user',escHtml(msg));
await callChat(msg);
}
function sendQuick(msg,withCase){
if(withCase&&currentCaseId){askAiAboutCase();return;}
if(window.innerWidth<=1024)mobCloseAll();
addMsg('user',escHtml(msg));callChat(msg);
}
// ── 네트워크 분석 패널 ───────────────────────────────────────────────
var _currentNetTab = 'ip';
var _allNetTabs = ['ip','trace','nmap','dns','ssl','vuln','hash','webscan','dirb','banner','cve'];
function promptIPTrack(){
document.getElementById('center-empty').style.display='none';
document.getElementById('center-case').style.display='none';
document.getElementById('center-ip').style.display='flex';
switchNetTab('ip');
nmapProfileChange();
setTimeout(()=>document.getElementById('ip-input-field').focus(),100);
}
function closeIPPanel(){
document.getElementById('center-ip').style.display='none';
if(currentCaseId){document.getElementById('center-case').style.display='flex';}
else{document.getElementById('center-empty').style.display='';}
}
function switchNetTab(tab){
_currentNetTab=tab;
_allNetTabs.forEach(t=>{
document.getElementById('ntab-'+t).classList.toggle('active',t===tab);
document.getElementById('npane-'+t).style.display=t===tab?'flex':'none';
});
const labels={ip:'조회',trace:'추적',nmap:'스캔',dns:'조회',ssl:'분석',vuln:'스캔',hash:'',webscan:'',dirb:'',banner:'그래빙',cve:''};
const noBtn=['hash','webscan','dirb','cve'];
const btn=document.getElementById('net-run-btn');
btn.style.display=noBtn.includes(tab)?'none':'';
btn.textContent=labels[tab]||'실행';
}
function netTabAction(){
const actions={ip:lookupIP,trace:runTraceroute,nmap:runNmap,dns:()=>runDNS(),ssl:runSSL,vuln:runVuln,banner:runBanner};
const fn=actions[_currentNetTab];
if(fn)fn();
}
function nmapProfileChange(){
const sel=document.getElementById('nmap-profile');
const custom=document.getElementById('nmap-custom-args');
const isCustom=sel.value==='custom';
custom.style.display=isCustom?'block':'none';
updateNmapCmd();
}
function updateNmapCmd(){
const target=(document.getElementById('ip-input-field').value||'<target>').trim();
const sel=document.getElementById('nmap-profile');
const args=sel.value==='custom'?(document.getElementById('nmap-custom-args').value||''):sel.value;
document.getElementById('nmap-cmd-preview').textContent=`nmap ${args} ${target}`;
}
document.addEventListener('input',e=>{
if(e.target.id==='ip-input-field'||e.target.id==='nmap-custom-args') updateNmapCmd();
});
function switchNmapTab(tab){
['ports','host','raw'].forEach(t=>{
document.getElementById('nrtab-'+t).classList.toggle('active',t===tab);
document.getElementById('nmap-pane-'+t).style.display=t===tab?'block':'none';
});
}
async function lookupIP(){
const ip=(document.getElementById('ip-input-field').value||'').trim();
if(!ip)return;
const result=document.getElementById('ip-result');
const mapWrap=document.getElementById('ip-map-wrap');
result.style.display='block';result.innerHTML='<span style="opacity:.5">조회 중...</span>';
mapWrap.style.display='none';
try{
const r=await fetch(`https://ipapi.co/${encodeURIComponent(ip)}/json/`);
const d=await r.json();
if(d.error){result.innerHTML='<span style="color:var(--err)">'+escHtml(d.reason)+'</span>';return;}
const lat=d.latitude,lon=d.longitude;
result.innerHTML=`<div style="display:grid;grid-template-columns:auto 1fr;gap:2px 12px;">
<span style="color:var(--muted)">IP</span><span>${escHtml(d.ip)}</span>
<span style="color:var(--muted)">위치</span><span>${escHtml(d.country_name)} · ${escHtml(d.region||'')} · ${escHtml(d.city||'')}</span>
<span style="color:var(--muted)">시간대</span><span>${escHtml(d.timezone||'-')}</span>
<span style="color:var(--muted)">ISP</span><span>${escHtml(d.org||'-')}</span>
<span style="color:var(--muted)">ASN</span><span>${escHtml(d.asn||'-')}</span>
<span style="color:var(--muted)">좌표</span><span>${lat}, ${lon} <a href="https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=10/${lat}/${lon}" target="_blank" style="color:#38bdf8;font-size:10px;">🗺</a></span>
</div>`;
document.getElementById('ip-map-frame').src=
`https://www.openstreetmap.org/export/embed.html?bbox=${lon-0.3},${lat-0.2},${lon+0.3},${lat+0.2}&layer=mapnik&marker=${lat},${lon}`;
mapWrap.style.display='block';
callChat(`IP ${ip} 조회: 국가=${d.country_name}, 지역=${d.region||''}/${d.city||''}, ISP=${d.org||'-'}. 이 IP 위험 여부를 간략히 분석해주세요.`);
}catch(e){result.innerHTML='<span style="color:var(--err)">조회 실패</span>';}
}
async function runTraceroute(){
const ip=(document.getElementById('ip-input-field').value||'').trim();
if(!ip)return;
const btn=document.getElementById('net-run-btn');
const status=document.getElementById('trace-status');
const resultEl=document.getElementById('trace-result');
btn.disabled=true;btn.textContent='추적 중...';
status.textContent='mtr 실행 중 (약 15~30초)...';
resultEl.innerHTML='';
try{
const r=await fetch('/api/detective/traceroute',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({ip})});
const d=await r.json();
if(!d.success){resultEl.innerHTML='<span style="color:var(--err)">'+escHtml(d.error)+'</span>';return;}
const hubs=d.data?.report?.hubs||[];
status.textContent=`${hubs.length}개 홉`;
const hdr=`<div style="display:grid;grid-template-columns:24px 1fr 48px 48px 72px;gap:4px;padding:2px 0;border-bottom:1px solid var(--line);font-size:10px;color:var(--muted);">
<span>#</span><span>호스트</span><span style="text-align:right">손실</span><span style="text-align:right">평균</span><span style="text-align:right">최소/최대</span></div>`;
const rows=hubs.map(h=>{
const loss=h.Loss||0;
const col=loss>50?'#ef4444':loss>10?'#f59e0b':'#4ade80';
return `<div style="display:grid;grid-template-columns:24px 1fr 48px 48px 72px;gap:4px;padding:2px 0;border-bottom:1px solid rgba(255,255,255,.04);align-items:center;">
<span style="color:var(--muted)">${h.count}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(h.host||'???')}</span>
<span style="color:${col};text-align:right">${loss}%</span>
<span style="text-align:right;color:var(--muted)">${h.Avg||0}ms</span>
<span style="text-align:right;color:var(--muted)">${h.Best||0}/${h.Wrst||0}ms</span>
</div>`;
}).join('');
resultEl.innerHTML=hdr+rows;
}catch(e){resultEl.innerHTML='<span style="color:var(--err)">'+escHtml(e.message)+'</span>';}
finally{btn.disabled=false;btn.textContent='추적';}
}
async function runNmap(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const sel=document.getElementById('nmap-profile');
const argsStr=sel.value==='custom'?(document.getElementById('nmap-custom-args').value||''):sel.value;
const args=argsStr.trim().split(/\s+/).filter(Boolean);
const btn=document.getElementById('net-run-btn');
const status=document.getElementById('nmap-status');
btn.disabled=true;btn.textContent='스캔 중...';
status.textContent='실행 중...';
document.getElementById('nmap-pane-ports').innerHTML='';
document.getElementById('nmap-pane-host').innerHTML='';
document.getElementById('nmap-pane-raw').textContent='';
try{
const r=await fetch('/api/detective/nmap',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,args})});
const d=await r.json();
if(!d.success){status.textContent='오류';document.getElementById('nmap-pane-ports').innerHTML='<span style="color:var(--err)">'+escHtml(d.error)+'</span>';return;}
parseNmapXml(d.xml,target);
}catch(e){status.textContent='오류';document.getElementById('nmap-pane-ports').innerHTML='<span style="color:var(--err)">'+escHtml(e.message)+'</span>';}
finally{btn.disabled=false;btn.textContent='스캔';}
}
function parseNmapXml(xml,target){
const doc=new DOMParser().parseFromString(xml,'text/xml');
const host=doc.querySelector('host');
const status=host?.querySelector('status');
const state=status?.getAttribute('state')||'unknown';
const addrs=[...doc.querySelectorAll('address')].map(a=>`${a.getAttribute('addrtype')}: ${a.getAttribute('addr')}`).join(' / ');
const hostnames=[...doc.querySelectorAll('hostname')].map(h=>h.getAttribute('name')).filter(Boolean).join(', ')||'-';
const ports=[...doc.querySelectorAll('port')];
const osMatch=doc.querySelector('osmatch');
const elapsed=doc.querySelector('finished')?.getAttribute('elapsed')||'?';
// 포트 탭
const portHdr=`<div style="display:grid;grid-template-columns:60px 55px 70px 1fr;gap:4px;padding:2px 0;border-bottom:1px solid var(--line);font-size:10px;color:var(--muted);">
<span>포트</span><span>상태</span><span>프로토콜</span><span>서비스</span></div>`;
if(ports.length){
const rows=ports.map(p=>{
const ps=p.querySelector('state')?.getAttribute('state')||'';
const svc=p.querySelector('service');
const name=svc?.getAttribute('name')||'';
const prod=svc?.getAttribute('product')||'';
const ver=svc?.getAttribute('version')||'';
const col=ps==='open'?'#4ade80':ps==='filtered'?'#f59e0b':'#6b7280';
return `<div class="nmap-port-row ${ps}">
<span style="color:#93c5fd">${escHtml(p.getAttribute('portid'))}</span>
<span style="color:${col}">${ps}</span>
<span style="color:var(--muted)">${escHtml(p.getAttribute('protocol'))}</span>
<span>${escHtml([name,prod,ver].filter(Boolean).join(' '))}</span>
</div>`;
}).join('');
document.getElementById('nmap-pane-ports').innerHTML=portHdr+rows;
}else{
document.getElementById('nmap-pane-ports').innerHTML=portHdr+'<div style="color:var(--muted);padding:6px 0;font-size:11px;">열린 포트 없음</div>';
}
// 호스트 탭
document.getElementById('nmap-pane-host').innerHTML=`<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 14px;">
<span style="color:var(--muted)">대상</span><span>${escHtml(target)}</span>
<span style="color:var(--muted)">상태</span><span style="color:${state==='up'?'#4ade80':'#ef4444'}">${state}</span>
<span style="color:var(--muted)">주소</span><span>${escHtml(addrs)}</span>
<span style="color:var(--muted)">호스트명</span><span>${escHtml(hostnames)}</span>
${osMatch?`<span style="color:var(--muted)">OS</span><span>${escHtml(osMatch.getAttribute('name')||'')} (${osMatch.getAttribute('accuracy')}%)</span>`:''}
<span style="color:var(--muted)">소요시간</span><span>${elapsed}s</span>
</div>`;
// Raw 탭
document.getElementById('nmap-pane-raw').textContent=xml;
const openCnt=ports.filter(p=>p.querySelector('state')?.getAttribute('state')==='open').length;
document.getElementById('nmap-status').textContent=`${state} · 열린포트 ${openCnt}개 · ${elapsed}s`;
}
// ── DNS / WHOIS ──────────────────────────────────────────────────────
async function runDNS(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const type=document.getElementById('dns-type').value;
const status=document.getElementById('dns-status');
const result=document.getElementById('dns-result');
status.textContent='dig 실행 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/dns',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,type})});
const d=await r.json();
status.textContent=d.success?`${type} 조회 완료`:'오류';
result.style.display='block';
result.textContent=d.success?(d.output||'(결과 없음)'):d.error;
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
async function runWHOIS(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('dns-status');
const result=document.getElementById('dns-result');
status.textContent='whois 조회 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/whois',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
status.textContent=d.success?'whois 완료':'오류';
result.style.display='block';
result.textContent=d.success?d.output:d.error;
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
// ── SSL/HTTP ─────────────────────────────────────────────────────────
function switchSslTab(tab){
['cert','headers'].forEach(t=>{
document.getElementById('ssltab-'+t).classList.toggle('active',t===tab);
document.getElementById('ssl-pane-'+t).style.display=t===tab?'block':'none';
});
}
function parseCert(raw){
const lines=raw.split('\n');
const out=[];
let inCert=false;
for(const l of lines){
if(l.includes('Certificate chain')||l.includes('subject=')||l.includes('issuer=')||l.includes('notBefore')||l.includes('notAfter')||l.includes('Verify return code'))
out.push(l.trim());
if(l.includes('-----BEGIN CERTIFICATE-----'))inCert=true;
if(inCert)continue;
}
return out.join('\n')||raw.slice(0,3000);
}
function colorHeaders(raw){
return raw.split('\n').map(l=>{
const sec=['Strict-Transport-Security','Content-Security-Policy','X-Frame-Options','X-Content-Type','X-XSS','Referrer-Policy','Permissions-Policy'];
const bad=['Server:','X-Powered-By:'];
if(sec.some(s=>l.startsWith(s)))return`<span style="color:#4ade80">${escHtml(l)}</span>`;
if(bad.some(s=>l.startsWith(s)))return`<span style="color:#f59e0b">${escHtml(l)}</span>`;
return escHtml(l);
}).join('\n');
}
async function runSSL(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('ssl-status');
status.textContent='분석 중...';
document.getElementById('ssl-pane-cert').textContent='';
document.getElementById('ssl-pane-headers').innerHTML='';
try{
const r=await fetch('/api/detective/ssl',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
if(!d.success){status.textContent='오류: '+d.error;return;}
document.getElementById('ssl-pane-cert').textContent=parseCert(d.cert||'');
document.getElementById('ssl-pane-headers').innerHTML=colorHeaders(d.headers||'');
status.textContent='완료';
}catch(e){status.textContent='오류: '+e.message;}
}
// ── Vuln Scan (SSE 스트리밍) ──────────────────────────────────────────
var _vulnAbort = null;
async function runVuln(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const scripts=document.getElementById('vuln-script').value;
const status=document.getElementById('vuln-status');
const result=document.getElementById('vuln-result');
const btn=document.getElementById('net-run-btn');
btn.textContent='⏹ 중지';btn.onclick=stopVuln;
status.textContent='스캔 중...';
result.style.display='block';result.textContent='';
_vulnAbort=new AbortController();
try{
const r=await fetch('/api/detective/vuln',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,scripts}),signal:_vulnAbort.signal});
if(!r.ok){result.textContent='서버 오류: '+r.status;status.textContent='오류';return;}
const reader=r.body.getReader();const dec=new TextDecoder();
let buf='';
while(true){
const{done,value}=await reader.read();
if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
const dataLine=part.split('\n').find(l=>l.startsWith('data:'));
if(!dataLine)continue;
try{
const obj=JSON.parse(dataLine.slice(5).trim());
if(obj.done){status.textContent=obj.error?'오류: '+obj.error:'완료';return;}
if(obj.line){
const isErr=obj.line.includes('Script execution failed')||obj.line.includes('ERROR:');
const span=document.createElement('span');
span.style.cssText=isErr?'opacity:.35;':'';
span.textContent=obj.line+'\n';
result.appendChild(span);
result.scrollTop=result.scrollHeight;
}
}catch{}
}
}
vulnReport(result.textContent,target,scripts);
}catch(e){
if(e.name==='AbortError')status.textContent='중지됨';
else{status.textContent='오류';result.textContent=e.message;}
}finally{
_vulnAbort=null;
btn.textContent='스캔';btn.onclick=netTabAction;
}
}
function vulnReport(raw, target, scripts){
const lines=raw.split('\n').filter(l=>l.trim()&&!l.includes('Script execution failed')&&!l.includes('Starting Nmap')&&!l.includes('Nmap done'));
const summary=lines.slice(0,80).join('\n');
callChat(`다음은 ${target}에 대한 nmap --script ${scripts} 스캔 결과야.\n\n\`\`\`\n${summary}\n\`\`\`\n\n보안 관점에서 주요 발견사항과 위험도를 간단히 요약해줘. 발견된 취약점, 열린 포트, 주의사항 위주로.`);
}
function stopVuln(){
if(_vulnAbort){_vulnAbort.abort();_vulnAbort=null;}
}
// ── 해시 / 인코딩 ────────────────────────────────────────────────────
async function hashDo(type){
const text=document.getElementById('hash-input').value||'';
let result='';
try{
if(type==='b64enc'){result=btoa(unescape(encodeURIComponent(text)));}
else if(type==='b64dec'){result=decodeURIComponent(escape(atob(text)));}
else if(type==='urlenc'){result=encodeURIComponent(text);}
else if(type==='urldec'){result=decodeURIComponent(text);}
else if(type==='hex'){result=Array.from(new TextEncoder().encode(text)).map(b=>b.toString(16).padStart(2,'0')).join(' ');}
else if(type==='fromhex'){result=new TextDecoder().decode(new Uint8Array(text.trim().split(/\s+/).map(h=>parseInt(h,16))));}
else{
const algo={'md5':null,'sha1':'SHA-1','sha256':'SHA-256','sha512':'SHA-512'}[type];
if(!algo){result='MD5는 브라우저 내장 미지원 (서버 해시 필요)';}
else{const buf=await crypto.subtle.digest(algo,new TextEncoder().encode(text));result=Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');}
}
}catch(e){result='오류: '+e.message;}
document.getElementById('hash-result-text').textContent=result;
document.getElementById('hash-output').style.display='block';
}
function copyHashResult(){
const text=document.getElementById('hash-result-text').textContent;
navigator.clipboard.writeText(text).catch(()=>{});
}
// ── SSE 스트리밍 헬퍼 ────────────────────────────────────────────────
async function sseStream(url, body, resultEl, statusEl, stopBtnId, onDone){
const abortCtrl = new AbortController();
if(stopBtnId) document.getElementById(stopBtnId).style.display='inline-block';
window['_abort_'+stopBtnId]=abortCtrl;
resultEl.style.display='block'; resultEl.textContent='';
try{
const r=await fetch(url,{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify(body),signal:abortCtrl.signal});
if(!r.ok){resultEl.textContent='서버 오류: '+r.status;return;}
const reader=r.body.getReader();const dec=new TextDecoder();
let buf='';
while(true){
const{done,value}=await reader.read();
if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
const dl=part.split('\n').find(l=>l.startsWith('data:'));
if(!dl)continue;
try{
const obj=JSON.parse(dl.slice(5).trim());
if(obj.done){if(statusEl)statusEl.textContent=obj.error?'오류: '+obj.error:'완료';if(onDone)onDone(resultEl.textContent);return;}
if(obj.line){resultEl.textContent+=obj.line+'\n';resultEl.scrollTop=resultEl.scrollHeight;}
}catch{}
}
}
}catch(e){
if(e.name==='AbortError'){if(statusEl)statusEl.textContent='중지됨';}
else{if(statusEl)statusEl.textContent='오류';resultEl.textContent=e.message;}
}finally{
if(stopBtnId)document.getElementById(stopBtnId).style.display='none';
}
}
// ── WhatWeb + Nikto ───────────────────────────────────────────────────
var _webscanAbort=null;
async function runWhatWeb(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('webscan-status');
const result=document.getElementById('webscan-result');
status.textContent='WhatWeb 분석 중...';
result.style.display='block';result.textContent='';
try{
const r=await fetch('/api/detective/whatweb',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target})});
const d=await r.json();
result.textContent=d.output||d.error;
status.textContent='완료';
callChat(`WhatWeb 결과 (${target}):\n${(d.output||'').slice(0,500)}\n\n기술 스택과 보안 관점 요약해줘.`);
}catch(e){status.textContent='오류';result.textContent=e.message;}
}
async function runNikto(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const status=document.getElementById('webscan-status');
const result=document.getElementById('webscan-result');
status.textContent='Nikto 스캔 중 (수분 소요)...';
document.getElementById('webscan-stop-btn').style.display='inline-block';
await sseStream('/api/detective/nikto',{target},result,status,'webscan-stop-btn',
(out)=>callChat(`Nikto 스캔 결과 (${target}):\n${out.slice(0,1000)}\n\n주요 취약점과 위험도 요약해줘.`));
}
function stopWebscan(){
const a=window['_abort_webscan-stop-btn'];
if(a)a.abort();
document.getElementById('webscan-stop-btn').style.display='none';
}
// ── Gobuster ──────────────────────────────────────────────────────────
async function runGobuster(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const wordlist=document.getElementById('dirb-wordlist').value;
const status=document.getElementById('dirb-status');
const result=document.getElementById('dirb-result');
const runBtn=document.getElementById('dirb-run-btn');
runBtn.disabled=true;
status.textContent='스캔 중...';
await sseStream('/api/detective/gobuster',{target,wordlist},result,status,'dirb-stop-btn',
(out)=>{
const found=out.split('\n').filter(l=>l.trim()&&!l.startsWith('Error')).length;
status.textContent=`완료 — ${found}개 발견`;
});
runBtn.disabled=false;
}
function stopDirb(){
const a=window['_abort_dirb-stop-btn'];
if(a)a.abort();
document.getElementById('dirb-stop-btn').style.display='none';
document.getElementById('dirb-run-btn').disabled=false;
}
// ── 배너 그래빙 ───────────────────────────────────────────────────────
async function runBanner(){
const target=(document.getElementById('ip-input-field').value||'').trim();
if(!target)return;
const port=parseInt(document.getElementById('banner-port').value)||80;
const status=document.getElementById('banner-status');
const result=document.getElementById('banner-result');
status.textContent='연결 중...';result.style.display='none';
try{
const r=await fetch('/api/detective/banner',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},authH()),credentials:'include',body:JSON.stringify({target,port})});
const d=await r.json();
result.style.display='block';
result.textContent=d.success?d.output:d.error;
status.textContent=d.success?`포트 ${port} 응답`:'오류';
}catch(e){status.textContent='오류';result.style.display='block';result.textContent=e.message;}
}
// ── CVE 검색 ─────────────────────────────────────────────────────────
async function searchCVE(){
const keyword=(document.getElementById('cve-keyword').value||'').trim();
if(!keyword)return;
const status=document.getElementById('cve-status');
const result=document.getElementById('cve-result');
status.textContent='NVD 검색 중...';result.style.display='none';
try{
const r=await fetch(`/api/detective/cve?keyword=${encodeURIComponent(keyword)}`,{headers:authH(),credentials:'include'});
const d=await r.json();
if(!d.success){status.textContent='오류: '+d.error;return;}
const vulns=d.data?.vulnerabilities||[];
status.textContent=`${d.data?.totalResults||0}건 중 ${vulns.length}건 표시`;
if(!vulns.length){result.style.display='block';result.innerHTML='<div style="color:var(--muted);font-size:11px;">결과 없음</div>';return;}
result.style.display='block';
result.innerHTML=vulns.map(v=>{
const cve=v.cve;
const id=cve.id;
const desc=(cve.descriptions?.find(d=>d.lang==='en')?.value||'').slice(0,200);
const score=cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseScore||cve.metrics?.cvssMetricV2?.[0]?.cvssData?.baseScore||'N/A';
const sev=cve.metrics?.cvssMetricV31?.[0]?.cvssData?.baseSeverity||'';
const col=sev==='CRITICAL'?'#ef4444':sev==='HIGH'?'#f59e0b':sev==='MEDIUM'?'#eab308':'#6b7280';
const published=(cve.published||'').slice(0,10);
return `<div style="background:var(--panel-2);border-radius:8px;padding:10px;margin-bottom:8px;border-left:3px solid ${col};">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span style="font-size:12px;font-weight:700;color:#93c5fd;">${escHtml(id)}</span>
<span style="font-size:10px;font-weight:700;color:${col};">${sev||'?'} ${score}</span>
<span style="font-size:10px;color:var(--muted);margin-left:auto;">${published}</span>
</div>
<div style="font-size:11px;color:var(--fg);line-height:1.5;">${escHtml(desc)}${desc.length>=200?'…':''}</div>
<a href="https://nvd.nist.gov/vuln/detail/${encodeURIComponent(id)}" target="_blank" style="font-size:10px;color:#38bdf8;">자세히 보기 →</a>
</div>`;
}).join('');
}catch(e){status.textContent='오류';result.style.display='block';result.innerHTML=escHtml(e.message);}
}
async function callChat(message){
const btn=document.getElementById('send-btn');
btn.disabled=true;btn.textContent='...';
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:getSessionId(),useTools:true,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
}finally{btn.disabled=false;btn.textContent='전송';}
}
async function callChatRaw(message){
const msgs=document.getElementById('chat-msgs');
const typingDiv=document.createElement('div');typingDiv.className='dt-msg assistant';
typingDiv.innerHTML='<div class="dt-typing"><div class="dt-dot"></div><div class="dt-dot"></div><div class="dt-dot"></div></div>';
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
let bubble=null,textBuf='',thinkBuf='';
try{
const body={message,sessionId:'dt_tmp_'+Math.random().toString(36).slice(2,8),useTools:false,
...(selectedModel?{model:selectedModel}:{}),
...(skillContext?{skillContext}:{})};
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
if(!r.ok)throw new Error('서버 오류 '+r.status);
typingDiv.remove();
const msgDiv=document.createElement('div');msgDiv.className='dt-msg assistant';
bubble=document.createElement('div');bubble.className='dt-bubble';
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
while(true){
const{done,value}=await reader.read();if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n');buf=parts.pop()||'';
for(const part of parts){
if(!part.startsWith('data: '))continue;
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
if(ev.type==='thinking_delta'&&ev.text)thinkBuf+=ev.text;
else if(ev.type==='token'||ev.type==='text_delta'){
textBuf+=(ev.text||'');
if(bubble){
bubble.innerHTML=(thinkBuf?`<div class="dt-thinking" onclick="this.classList.toggle('exp')">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>`:'')+renderMd(textBuf);
msgs.scrollTop=msgs.scrollHeight;
}
}
}
}
return textBuf;
}catch(e){
typingDiv.remove();
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:var(--err)">오류: '+escHtml(e.message)+'</span>';}
return null;
}
}
async function clearChat(){
if(!confirm('이 컨텍스트의 대화를 지울까요?'))return;
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>